spikard-core 0.15.0

Shared transport-agnostic primitives for Spikard runtimes
Documentation
//! RFC 9457 Problem Details for HTTP APIs
//!
//! Implements the standard Problem Details format (RFC 9457, July 2023) for HTTP error responses.
//! This replaces framework-specific error formats with the IETF standard.
//!
//! # References
//! - [RFC 9457: Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457.html)
//! - [RFC 9110: HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html)

use crate::validation::ValidationError;
use http::StatusCode;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;

/// RFC 9457 Problem Details for HTTP APIs
///
/// A machine-readable format for specifying errors in HTTP API responses.
/// Per RFC 9457, all fields are optional. The `type` field defaults to "about:blank"
/// if not specified.
///
/// # Content-Type
/// Responses using this struct should set:
/// ```text
/// Content-Type: application/problem+json
/// ```
///
/// # Example
/// ```json
/// {
///   "type": "https://spikard.dev/errors/validation-error",
///   "title": "Request Validation Failed",
///   "status": 422,
///   "detail": "2 validation errors in request body",
///   "errors": [...]
/// }
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProblemDetails {
    /// A URI reference that identifies the problem type.
    /// Defaults to "about:blank" when absent.
    /// Should be a stable, human-readable identifier for the problem type.
    #[serde(rename = "type")]
    pub type_uri: String,

    /// A short, human-readable summary of the problem type.
    /// Should not change from occurrence to occurrence of the problem.
    pub title: String,

    /// The HTTP status code generated by the origin server.
    /// This is advisory; the actual HTTP status code takes precedence.
    pub status: u16,

    /// A human-readable explanation specific to this occurrence of the problem.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,

    /// A URI reference that identifies the specific occurrence of the problem.
    /// It may or may not yield further information if dereferenced.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub instance: Option<String>,

    /// Extension members - problem-type-specific data.
    /// For validation errors, this typically contains an "errors" array.
    #[serde(flatten, skip_serializing_if = "HashMap::is_empty")]
    pub extensions: HashMap<String, Value>,
}

impl ProblemDetails {
    /// Standard type URI for validation errors (422)
    pub const TYPE_VALIDATION_ERROR: &'static str = "https://spikard.dev/errors/validation-error";

    /// Standard type URI for not found errors (404)
    pub const TYPE_NOT_FOUND: &'static str = "https://spikard.dev/errors/not-found";

    /// Standard type URI for method not allowed (405)
    pub const TYPE_METHOD_NOT_ALLOWED: &'static str = "https://spikard.dev/errors/method-not-allowed";

    /// Standard type URI for internal server error (500)
    pub const TYPE_INTERNAL_SERVER_ERROR: &'static str = "https://spikard.dev/errors/internal-server-error";

    /// Standard type URI for bad request (400)
    pub const TYPE_BAD_REQUEST: &'static str = "https://spikard.dev/errors/bad-request";

    /// Create a new `ProblemDetails` with required fields
    #[must_use]
    pub fn new(type_uri: impl Into<String>, title: impl Into<String>, status: StatusCode) -> Self {
        Self {
            type_uri: type_uri.into(),
            title: title.into(),
            status: status.as_u16(),
            detail: None,
            instance: None,
            extensions: HashMap::new(),
        }
    }

    /// Set the detail field
    #[must_use]
    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
        self.detail = Some(detail.into());
        self
    }

    /// Set the instance field
    #[must_use]
    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
        self.instance = Some(instance.into());
        self
    }

    /// Add an extension field
    #[must_use]
    pub fn with_extension(mut self, key: impl Into<String>, value: Value) -> Self {
        self.extensions.insert(key.into(), value);
        self
    }

    /// Add all extensions from a JSON object
    #[must_use]
    #[allow(clippy::needless_pass_by_value)]
    pub fn with_extensions(mut self, extensions: Value) -> Self {
        if let Some(obj) = extensions.as_object() {
            for (key, value) in obj {
                self.extensions.insert(key.clone(), value.clone());
            }
        }
        self
    }

    /// Create a validation error Problem Details from `ValidationError`
    ///
    /// This converts the FastAPI-style validation errors to RFC 9457 format:
    /// - `type`: <https://spikard.dev/errors/validation-error>
    /// - `title`: "Request Validation Failed"
    /// - `status`: 422
    /// - `detail`: Summary of error count
    /// - `errors`: Array of validation error details (as extension field)
    #[must_use]
    pub fn from_validation_error(error: &ValidationError) -> Self {
        let error_count = error.errors.len();
        let detail = if error_count == 1 {
            "1 validation error in request".to_string()
        } else {
            format!("{error_count} validation errors in request")
        };

        let errors_json = serde_json::to_value(&error.errors).unwrap_or_else(|_| serde_json::Value::Array(vec![]));

        Self::new(
            Self::TYPE_VALIDATION_ERROR,
            "Request Validation Failed",
            StatusCode::UNPROCESSABLE_ENTITY,
        )
        .with_detail(detail)
        .with_extension("errors", errors_json)
    }

    /// Create a not found error
    pub fn not_found(detail: impl Into<String>) -> Self {
        Self::new(Self::TYPE_NOT_FOUND, "Resource Not Found", StatusCode::NOT_FOUND).with_detail(detail)
    }

    /// Create a method not allowed error
    pub fn method_not_allowed(detail: impl Into<String>) -> Self {
        Self::new(
            Self::TYPE_METHOD_NOT_ALLOWED,
            "Method Not Allowed",
            StatusCode::METHOD_NOT_ALLOWED,
        )
        .with_detail(detail)
    }

    /// Create an internal server error
    pub fn internal_server_error(detail: impl Into<String>) -> Self {
        Self::new(
            Self::TYPE_INTERNAL_SERVER_ERROR,
            "Internal Server Error",
            StatusCode::INTERNAL_SERVER_ERROR,
        )
        .with_detail(detail)
    }

    /// Create an internal server error with debug information
    ///
    /// Includes exception details, traceback, and request data for debugging.
    /// Only use in development/debug mode.
    pub fn internal_server_error_debug(
        detail: impl Into<String>,
        exception: impl Into<String>,
        traceback: impl Into<String>,
        request_data: Value,
    ) -> Self {
        Self::new(
            Self::TYPE_INTERNAL_SERVER_ERROR,
            "Internal Server Error",
            StatusCode::INTERNAL_SERVER_ERROR,
        )
        .with_detail(detail)
        .with_extension("exception", Value::String(exception.into()))
        .with_extension("traceback", Value::String(traceback.into()))
        .with_extension("request_data", request_data)
    }

    /// Create a bad request error
    pub fn bad_request(detail: impl Into<String>) -> Self {
        Self::new(Self::TYPE_BAD_REQUEST, "Bad Request", StatusCode::BAD_REQUEST).with_detail(detail)
    }

    /// Get the HTTP status code
    #[must_use]
    pub fn status_code(&self) -> StatusCode {
        StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
    }

    /// Serialize to JSON string
    ///
    /// # Errors
    /// Returns an error if the serialization fails.
    pub fn to_json(&self) -> Result<String, serde_json::Error> {
        serde_json::to_string(self)
    }

    /// Serialize to pretty JSON string
    ///
    /// # Errors
    /// Returns an error if the serialization fails.
    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
        serde_json::to_string_pretty(self)
    }
}

/// Content-Type for RFC 9457 Problem Details
pub const CONTENT_TYPE_PROBLEM_JSON: &str = "application/problem+json; charset=utf-8";

#[cfg(test)]
mod tests {
    use super::*;
    use crate::validation::{ValidationError, ValidationErrorDetail};
    use serde_json::json;

    #[test]
    fn test_validation_error_conversion() {
        let validation_error = ValidationError {
            errors: vec![
                ValidationErrorDetail {
                    error_type: "missing".to_string(),
                    loc: vec!["body".to_string(), "username".to_string()],
                    msg: "Field required".to_string(),
                    input: Value::String(String::new()),
                    ctx: None,
                },
                ValidationErrorDetail {
                    error_type: "string_too_short".to_string(),
                    loc: vec!["body".to_string(), "password".to_string()],
                    msg: "String should have at least 8 characters".to_string(),
                    input: Value::String("pass".to_string()),
                    ctx: Some(json!({"min_length": 8})),
                },
            ],
        };

        let problem = ProblemDetails::from_validation_error(&validation_error);

        assert_eq!(problem.type_uri, ProblemDetails::TYPE_VALIDATION_ERROR);
        assert_eq!(problem.title, "Request Validation Failed");
        assert_eq!(problem.status, 422);
        assert_eq!(problem.detail, Some("2 validation errors in request".to_string()));

        let errors = problem.extensions.get("errors").unwrap();
        assert!(errors.is_array());
        assert_eq!(errors.as_array().unwrap().len(), 2);
    }

    #[test]
    fn test_problem_details_serialization() {
        let problem = ProblemDetails::new(
            "https://example.com/probs/out-of-credit",
            "You do not have enough credit",
            StatusCode::FORBIDDEN,
        )
        .with_detail("Your current balance is 30, but that costs 50.")
        .with_instance("/account/12345/msgs/abc")
        .with_extension("balance", json!(30))
        .with_extension("accounts", json!(["/account/12345", "/account/67890"]));

        let json_str = problem.to_json_pretty().unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();

        assert_eq!(parsed["type"], "https://example.com/probs/out-of-credit");
        assert_eq!(parsed["title"], "You do not have enough credit");
        assert_eq!(parsed["status"], 403);
        assert_eq!(parsed["detail"], "Your current balance is 30, but that costs 50.");
        assert_eq!(parsed["instance"], "/account/12345/msgs/abc");
        assert_eq!(parsed["balance"], 30);
    }

    #[test]
    fn test_not_found_error() {
        let problem = ProblemDetails::not_found("No route matches GET /api/users/999");

        assert_eq!(problem.type_uri, ProblemDetails::TYPE_NOT_FOUND);
        assert_eq!(problem.title, "Resource Not Found");
        assert_eq!(problem.status, 404);
        assert_eq!(problem.detail, Some("No route matches GET /api/users/999".to_string()));
    }

    #[test]
    fn test_internal_server_error_debug() {
        let request_data = json!({
            "path_params": {},
            "query_params": {},
            "body": {"username": "test"}
        });

        let problem = ProblemDetails::internal_server_error_debug(
            "Python handler raised KeyError",
            "KeyError: 'username'",
            "Traceback (most recent call last):\n  ...",
            request_data,
        );

        assert_eq!(problem.type_uri, ProblemDetails::TYPE_INTERNAL_SERVER_ERROR);
        assert_eq!(problem.status, 500);
        assert!(problem.extensions.contains_key("exception"));
        assert!(problem.extensions.contains_key("traceback"));
        assert!(problem.extensions.contains_key("request_data"));
    }

    #[test]
    fn test_validation_error_conversion_single_error_uses_singular_detail() {
        let validation_error = ValidationError {
            errors: vec![ValidationErrorDetail {
                error_type: "missing".to_string(),
                loc: vec!["query".to_string(), "id".to_string()],
                msg: "Field required".to_string(),
                input: Value::Null,
                ctx: None,
            }],
        };

        let problem = ProblemDetails::from_validation_error(&validation_error);
        assert_eq!(problem.status, 422);
        assert_eq!(problem.detail, Some("1 validation error in request".to_string()));
    }

    #[test]
    fn test_with_extensions_ignores_non_object_values() {
        let problem =
            ProblemDetails::new("about:blank", "Test", StatusCode::BAD_REQUEST).with_extensions(Value::Bool(true));
        assert!(problem.extensions.is_empty());
    }

    #[test]
    fn test_content_type_constant() {
        assert_eq!(CONTENT_TYPE_PROBLEM_JSON, "application/problem+json; charset=utf-8");
    }
}