baukit-http 0.9.0

Shared Axum middleware, error, and HTTP telemetry conventions.
Documentation
use std::{fmt, time::Duration};

use axum::http::{HeaderName, HeaderValue};
use baukit_config::HttpConfig;

use crate::error::is_valid_error_code;

const DEFAULT_BODY_SIZE_LIMIT: usize = 2 * 1024 * 1024;
const DEFAULT_CONCURRENCY_LIMIT: usize = 1_024;
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);

/// Stable error codes for each JSON extractor rejection class.
///
/// Supplying this through [`HttpOptions::with_json_rejection_codes`] enables
/// class-specific statuses and codes. Products still choose route body limits
/// and the text shown to users.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct JsonRejectionCodes {
    body_too_large: String,
    content_type: String,
    syntax: String,
    data_shape: String,
}

impl JsonRejectionCodes {
    /// Creates a class-specific code set.
    ///
    /// Each code must be a non-empty snake_case identifier.
    pub fn new(
        body_too_large: impl Into<String>,
        content_type: impl Into<String>,
        syntax: impl Into<String>,
        data_shape: impl Into<String>,
    ) -> Result<Self, HttpOptionsError> {
        Ok(Self {
            body_too_large: valid_json_rejection_code(body_too_large.into())?,
            content_type: valid_json_rejection_code(content_type.into())?,
            syntax: valid_json_rejection_code(syntax.into())?,
            data_shape: valid_json_rejection_code(data_shape.into())?,
        })
    }

    /// Returns the code for a body rejected because it is too large.
    #[must_use]
    pub fn body_too_large(&self) -> &str {
        &self.body_too_large
    }

    /// Returns the code for a missing or invalid JSON content type.
    #[must_use]
    pub fn content_type(&self) -> &str {
        &self.content_type
    }

    /// Returns the code for malformed JSON syntax.
    #[must_use]
    pub fn syntax(&self) -> &str {
        &self.syntax
    }

    /// Returns the code for JSON that does not match the target data shape.
    #[must_use]
    pub fn data_shape(&self) -> &str {
        &self.data_shape
    }
}

impl Default for JsonRejectionCodes {
    fn default() -> Self {
        Self {
            body_too_large: "payload_too_large".to_owned(),
            content_type: "unsupported_media_type".to_owned(),
            syntax: "invalid_json".to_owned(),
            data_shape: "validation_failed".to_owned(),
        }
    }
}

/// Whether the HTTP layers add a default `Cache-Control` header to responses.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ResponseCachePolicy {
    /// Adds `Cache-Control: private, no-store` to every response that does not
    /// already carry a `Cache-Control` header.
    #[default]
    PrivateNoStore,
    /// Leaves `Cache-Control` entirely to handlers and outer layers.
    HandlerOwned,
}

#[derive(Clone, Debug)]
pub(crate) enum JsonRejectionMode {
    Legacy(String),
    Classified(JsonRejectionCodes),
}

/// Configuration for the shared HTTP layer stack.
///
/// CORS denies cross-origin access until explicit origins are supplied through
/// [`HttpOptions::with_allowed_origins`]. Wildcard origins are rejected, which
/// also prevents the invalid wildcard-with-credentials combination.
#[derive(Clone, Debug)]
pub struct HttpOptions {
    /// Maximum time allowed for one request, including concurrency queueing.
    pub request_timeout: Duration,
    /// Maximum accepted request body size in bytes.
    pub body_size_limit: usize,
    /// Maximum number of requests processed concurrently.
    pub concurrency_limit: usize,
    /// Whether browsers may send credentials to explicitly allowed origins.
    pub allow_credentials: bool,
    pub(crate) allowed_origins: Vec<HeaderValue>,
    pub(crate) additional_allowed_headers: Vec<HeaderName>,
    pub(crate) additional_exposed_headers: Vec<HeaderName>,
    pub(crate) response_cache_policy: ResponseCachePolicy,
    pub(crate) json_rejection_mode: JsonRejectionMode,
}

impl HttpOptions {
    /// Replaces the CORS origin allowlist.
    ///
    /// Each origin must be a valid HTTP header value and must not be `*`.
    pub fn with_allowed_origins<I, S>(mut self, origins: I) -> Result<Self, HttpOptionsError>
    where
        I: IntoIterator<Item = S>,
        S: AsRef<str>,
    {
        self.allowed_origins = parse_origins(origins)?;
        Ok(self)
    }

    /// Returns the explicit CORS origin allowlist.
    #[must_use]
    pub fn allowed_origins(&self) -> &[HeaderValue] {
        &self.allowed_origins
    }

    /// Adds request headers to the standard CORS allowlist.
    ///
    /// The built-in `Authorization`, `Content-Type`, `If-Match`, `Idempotency-Key`,
    /// `x-request-id`, `traceparent`, and `tracestate` headers remain allowed. Duplicate header
    /// names are ignored.
    pub fn with_additional_allowed_headers<I, S>(
        mut self,
        headers: I,
    ) -> Result<Self, HttpOptionsError>
    where
        I: IntoIterator<Item = S>,
        S: AsRef<str>,
    {
        extend_header_names(&mut self.additional_allowed_headers, headers)?;
        Ok(self)
    }

    /// Returns the request headers added to the standard CORS allowlist.
    #[must_use]
    pub fn additional_allowed_headers(&self) -> &[HeaderName] {
        &self.additional_allowed_headers
    }

    /// Adds response headers to the standard CORS exposed set.
    ///
    /// The built-in `x-request-id`, `traceparent`, `tracestate`, `Retry-After`,
    /// `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `ETag`, and
    /// `Location` headers remain exposed. Duplicate header names are ignored.
    pub fn with_additional_exposed_headers<I, S>(
        mut self,
        headers: I,
    ) -> Result<Self, HttpOptionsError>
    where
        I: IntoIterator<Item = S>,
        S: AsRef<str>,
    {
        extend_header_names(&mut self.additional_exposed_headers, headers)?;
        Ok(self)
    }

    /// Returns the response headers added to the standard CORS exposed set.
    #[must_use]
    pub fn additional_exposed_headers(&self) -> &[HeaderName] {
        &self.additional_exposed_headers
    }

    /// Replaces the response cache policy.
    ///
    /// The default is [`ResponseCachePolicy::PrivateNoStore`].
    #[must_use]
    pub const fn with_response_cache_policy(mut self, policy: ResponseCachePolicy) -> Self {
        self.response_cache_policy = policy;
        self
    }

    /// Returns the response cache policy.
    #[must_use]
    pub const fn response_cache_policy(&self) -> ResponseCachePolicy {
        self.response_cache_policy
    }

    /// Overrides the error code returned when [`crate::ApiJson`] rejects a body.
    ///
    /// The default is `validation_failed`. The replacement must be a non-empty
    /// snake_case identifier.
    pub fn with_json_rejection_code(
        mut self,
        code: impl Into<String>,
    ) -> Result<Self, HttpOptionsError> {
        self.json_rejection_mode =
            JsonRejectionMode::Legacy(valid_json_rejection_code(code.into())?);
        Ok(self)
    }

    /// Enables class-specific JSON rejection statuses and codes.
    ///
    /// This preserves Axum's protocol statuses, including `413 Payload Too
    /// Large`, `415 Unsupported Media Type`, and `422 Unprocessable Entity`.
    #[must_use]
    pub fn with_json_rejection_codes(mut self, codes: JsonRejectionCodes) -> Self {
        self.json_rejection_mode = JsonRejectionMode::Classified(codes);
        self
    }

    /// Returns the single JSON rejection code in compatibility mode.
    ///
    /// In class-specific mode this returns the data-shape code to preserve the
    /// existing return type. Use [`HttpOptions::json_rejection_codes`] to read
    /// all four codes.
    #[must_use]
    pub fn json_rejection_code(&self) -> &str {
        match &self.json_rejection_mode {
            JsonRejectionMode::Legacy(code) => code,
            JsonRejectionMode::Classified(codes) => codes.data_shape(),
        }
    }

    /// Returns the class-specific JSON rejection codes when that mode is active.
    #[must_use]
    pub fn json_rejection_codes(&self) -> Option<&JsonRejectionCodes> {
        match &self.json_rejection_mode {
            JsonRejectionMode::Legacy(_) => None,
            JsonRejectionMode::Classified(codes) => Some(codes),
        }
    }

    /// Builds options from Baukit's standard public-listener configuration.
    ///
    /// Listener address and port are intentionally ignored because they belong
    /// to runtime composition rather than an Axum layer.
    pub fn from_config(config: &HttpConfig) -> Result<Self, HttpOptionsError> {
        Self {
            request_timeout: config.request_timeout,
            body_size_limit: config.body_size_limit,
            concurrency_limit: config.concurrency_limit,
            allow_credentials: false,
            allowed_origins: parse_origins(&config.cors_allowed_origins)?,
            additional_allowed_headers: Vec::new(),
            additional_exposed_headers: Vec::new(),
            response_cache_policy: ResponseCachePolicy::default(),
            json_rejection_mode: JsonRejectionMode::Legacy("validation_failed".to_owned()),
        }
        .validate()
    }

    pub(crate) fn validate(self) -> Result<Self, HttpOptionsError> {
        if self.request_timeout.is_zero() {
            return Err(HttpOptionsError::ZeroRequestTimeout);
        }
        if self.body_size_limit == 0 {
            return Err(HttpOptionsError::ZeroBodySizeLimit);
        }
        if self.concurrency_limit == 0 {
            return Err(HttpOptionsError::ZeroConcurrencyLimit);
        }
        Ok(self)
    }
}

impl Default for HttpOptions {
    fn default() -> Self {
        Self {
            request_timeout: DEFAULT_REQUEST_TIMEOUT,
            body_size_limit: DEFAULT_BODY_SIZE_LIMIT,
            concurrency_limit: DEFAULT_CONCURRENCY_LIMIT,
            allow_credentials: false,
            allowed_origins: Vec::new(),
            additional_allowed_headers: Vec::new(),
            additional_exposed_headers: Vec::new(),
            response_cache_policy: ResponseCachePolicy::default(),
            json_rejection_mode: JsonRejectionMode::Legacy("validation_failed".to_owned()),
        }
    }
}

/// An invalid shared HTTP layer option.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum HttpOptionsError {
    /// A CORS origin is not representable as an HTTP header value.
    InvalidOrigin(String),
    /// Wildcard CORS origins are deliberately unsupported.
    WildcardOrigin,
    /// A CORS allowed or exposed header name is invalid.
    InvalidHeaderName(String),
    /// The JSON rejection code is not a snake_case identifier.
    InvalidJsonRejectionCode(String),
    /// Request timeout must be non-zero.
    ZeroRequestTimeout,
    /// Body size limit must be non-zero.
    ZeroBodySizeLimit,
    /// Concurrency limit must be non-zero.
    ZeroConcurrencyLimit,
}

impl fmt::Display for HttpOptionsError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidOrigin(origin) => write!(formatter, "invalid CORS origin `{origin}`"),
            Self::WildcardOrigin => formatter.write_str(
                "wildcard CORS origins are unsupported; provide explicit allowed origins",
            ),
            Self::InvalidHeaderName(name) => {
                write!(formatter, "invalid CORS header name `{name}`")
            }
            Self::InvalidJsonRejectionCode(code) => write!(
                formatter,
                "invalid JSON rejection error code `{code}`; expected snake_case"
            ),
            Self::ZeroRequestTimeout => formatter.write_str("request timeout must be non-zero"),
            Self::ZeroBodySizeLimit => formatter.write_str("body size limit must be non-zero"),
            Self::ZeroConcurrencyLimit => formatter.write_str("concurrency limit must be non-zero"),
        }
    }
}

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

fn parse_origins<I, S>(origins: I) -> Result<Vec<HeaderValue>, HttpOptionsError>
where
    I: IntoIterator<Item = S>,
    S: AsRef<str>,
{
    origins
        .into_iter()
        .map(|origin| {
            let origin = origin.as_ref();
            if origin == "*" {
                return Err(HttpOptionsError::WildcardOrigin);
            }
            HeaderValue::from_str(origin)
                .map_err(|_| HttpOptionsError::InvalidOrigin(origin.to_owned()))
        })
        .collect()
}

fn extend_header_names<I, S>(
    names: &mut Vec<HeaderName>,
    headers: I,
) -> Result<(), HttpOptionsError>
where
    I: IntoIterator<Item = S>,
    S: AsRef<str>,
{
    for header in headers {
        let header = header.as_ref();
        let parsed = HeaderName::from_bytes(header.as_bytes())
            .map_err(|_| HttpOptionsError::InvalidHeaderName(header.to_owned()))?;
        if !names.contains(&parsed) {
            names.push(parsed);
        }
    }
    Ok(())
}

fn valid_json_rejection_code(code: String) -> Result<String, HttpOptionsError> {
    if is_valid_error_code(&code) {
        Ok(code)
    } else {
        Err(HttpOptionsError::InvalidJsonRejectionCode(code))
    }
}