Skip to main content

baukit_http/
options.rs

1use std::{fmt, time::Duration};
2
3use axum::http::{HeaderName, HeaderValue};
4use baukit_config::HttpConfig;
5
6use crate::error::is_valid_error_code;
7
8const DEFAULT_BODY_SIZE_LIMIT: usize = 2 * 1024 * 1024;
9const DEFAULT_CONCURRENCY_LIMIT: usize = 1_024;
10const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
11
12/// Configuration for the shared HTTP layer stack.
13///
14/// CORS denies cross-origin access until explicit origins are supplied through
15/// [`HttpOptions::with_allowed_origins`]. Wildcard origins are rejected, which
16/// also prevents the invalid wildcard-with-credentials combination.
17#[derive(Clone, Debug)]
18pub struct HttpOptions {
19    /// Maximum time allowed for one request, including concurrency queueing.
20    pub request_timeout: Duration,
21    /// Maximum accepted request body size in bytes.
22    pub body_size_limit: usize,
23    /// Maximum number of requests processed concurrently.
24    pub concurrency_limit: usize,
25    /// Whether browsers may send credentials to explicitly allowed origins.
26    pub allow_credentials: bool,
27    pub(crate) allowed_origins: Vec<HeaderValue>,
28    pub(crate) additional_allowed_headers: Vec<HeaderName>,
29    pub(crate) json_rejection_code: String,
30}
31
32impl HttpOptions {
33    /// Replaces the CORS origin allowlist.
34    ///
35    /// Each origin must be a valid HTTP header value and must not be `*`.
36    pub fn with_allowed_origins<I, S>(mut self, origins: I) -> Result<Self, HttpOptionsError>
37    where
38        I: IntoIterator<Item = S>,
39        S: AsRef<str>,
40    {
41        self.allowed_origins = parse_origins(origins)?;
42        Ok(self)
43    }
44
45    /// Returns the explicit CORS origin allowlist.
46    #[must_use]
47    pub fn allowed_origins(&self) -> &[HeaderValue] {
48        &self.allowed_origins
49    }
50
51    /// Adds request headers to the standard CORS allowlist.
52    ///
53    /// The built-in `Authorization`, `Content-Type`, `x-request-id`,
54    /// `traceparent`, and `tracestate` headers remain allowed. Duplicate header
55    /// names are ignored.
56    pub fn with_additional_allowed_headers<I, S>(
57        mut self,
58        headers: I,
59    ) -> Result<Self, HttpOptionsError>
60    where
61        I: IntoIterator<Item = S>,
62        S: AsRef<str>,
63    {
64        for header in headers {
65            let header = header.as_ref();
66            let parsed = HeaderName::from_bytes(header.as_bytes())
67                .map_err(|_| HttpOptionsError::InvalidHeaderName(header.to_owned()))?;
68            if !self.additional_allowed_headers.contains(&parsed) {
69                self.additional_allowed_headers.push(parsed);
70            }
71        }
72        Ok(self)
73    }
74
75    /// Returns the request headers added to the standard CORS allowlist.
76    #[must_use]
77    pub fn additional_allowed_headers(&self) -> &[HeaderName] {
78        &self.additional_allowed_headers
79    }
80
81    /// Overrides the error code returned when [`crate::ApiJson`] rejects a body.
82    ///
83    /// The default is `validation_failed`. The replacement must be a non-empty
84    /// snake_case identifier.
85    pub fn with_json_rejection_code(
86        mut self,
87        code: impl Into<String>,
88    ) -> Result<Self, HttpOptionsError> {
89        let code = code.into();
90        if !is_valid_error_code(&code) {
91            return Err(HttpOptionsError::InvalidJsonRejectionCode(code));
92        }
93        self.json_rejection_code = code;
94        Ok(self)
95    }
96
97    /// Returns the error code used for JSON extractor rejections.
98    #[must_use]
99    pub fn json_rejection_code(&self) -> &str {
100        &self.json_rejection_code
101    }
102
103    /// Builds options from Baukit's standard public-listener configuration.
104    ///
105    /// Listener address and port are intentionally ignored because they belong
106    /// to runtime composition rather than an Axum layer.
107    pub fn from_config(config: &HttpConfig) -> Result<Self, HttpOptionsError> {
108        Self {
109            request_timeout: config.request_timeout,
110            body_size_limit: config.body_size_limit,
111            concurrency_limit: config.concurrency_limit,
112            allow_credentials: false,
113            allowed_origins: parse_origins(&config.cors_allowed_origins)?,
114            additional_allowed_headers: Vec::new(),
115            json_rejection_code: "validation_failed".to_owned(),
116        }
117        .validate()
118    }
119
120    pub(crate) fn validate(self) -> Result<Self, HttpOptionsError> {
121        if self.request_timeout.is_zero() {
122            return Err(HttpOptionsError::ZeroRequestTimeout);
123        }
124        if self.body_size_limit == 0 {
125            return Err(HttpOptionsError::ZeroBodySizeLimit);
126        }
127        if self.concurrency_limit == 0 {
128            return Err(HttpOptionsError::ZeroConcurrencyLimit);
129        }
130        Ok(self)
131    }
132}
133
134impl Default for HttpOptions {
135    fn default() -> Self {
136        Self {
137            request_timeout: DEFAULT_REQUEST_TIMEOUT,
138            body_size_limit: DEFAULT_BODY_SIZE_LIMIT,
139            concurrency_limit: DEFAULT_CONCURRENCY_LIMIT,
140            allow_credentials: false,
141            allowed_origins: Vec::new(),
142            additional_allowed_headers: Vec::new(),
143            json_rejection_code: "validation_failed".to_owned(),
144        }
145    }
146}
147
148/// An invalid shared HTTP layer option.
149#[derive(Clone, Debug, Eq, PartialEq)]
150pub enum HttpOptionsError {
151    /// A CORS origin is not representable as an HTTP header value.
152    InvalidOrigin(String),
153    /// Wildcard CORS origins are deliberately unsupported.
154    WildcardOrigin,
155    /// A requested CORS header name is invalid.
156    InvalidHeaderName(String),
157    /// The JSON rejection code is not a snake_case identifier.
158    InvalidJsonRejectionCode(String),
159    /// Request timeout must be non-zero.
160    ZeroRequestTimeout,
161    /// Body size limit must be non-zero.
162    ZeroBodySizeLimit,
163    /// Concurrency limit must be non-zero.
164    ZeroConcurrencyLimit,
165}
166
167impl fmt::Display for HttpOptionsError {
168    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
169        match self {
170            Self::InvalidOrigin(origin) => write!(formatter, "invalid CORS origin `{origin}`"),
171            Self::WildcardOrigin => formatter.write_str(
172                "wildcard CORS origins are unsupported; provide explicit allowed origins",
173            ),
174            Self::InvalidHeaderName(name) => {
175                write!(formatter, "invalid CORS request header name `{name}`")
176            }
177            Self::InvalidJsonRejectionCode(code) => write!(
178                formatter,
179                "invalid JSON rejection error code `{code}`; expected snake_case"
180            ),
181            Self::ZeroRequestTimeout => formatter.write_str("request timeout must be non-zero"),
182            Self::ZeroBodySizeLimit => formatter.write_str("body size limit must be non-zero"),
183            Self::ZeroConcurrencyLimit => formatter.write_str("concurrency limit must be non-zero"),
184        }
185    }
186}
187
188impl std::error::Error for HttpOptionsError {}
189
190fn parse_origins<I, S>(origins: I) -> Result<Vec<HeaderValue>, HttpOptionsError>
191where
192    I: IntoIterator<Item = S>,
193    S: AsRef<str>,
194{
195    origins
196        .into_iter()
197        .map(|origin| {
198            let origin = origin.as_ref();
199            if origin == "*" {
200                return Err(HttpOptionsError::WildcardOrigin);
201            }
202            HeaderValue::from_str(origin)
203                .map_err(|_| HttpOptionsError::InvalidOrigin(origin.to_owned()))
204        })
205        .collect()
206}