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/// Stable error codes for each JSON extractor rejection class.
13///
14/// Supplying this through [`HttpOptions::with_json_rejection_codes`] enables
15/// class-specific statuses and codes. Products still choose route body limits
16/// and the text shown to users.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct JsonRejectionCodes {
19    body_too_large: String,
20    content_type: String,
21    syntax: String,
22    data_shape: String,
23}
24
25impl JsonRejectionCodes {
26    /// Creates a class-specific code set.
27    ///
28    /// Each code must be a non-empty snake_case identifier.
29    pub fn new(
30        body_too_large: impl Into<String>,
31        content_type: impl Into<String>,
32        syntax: impl Into<String>,
33        data_shape: impl Into<String>,
34    ) -> Result<Self, HttpOptionsError> {
35        Ok(Self {
36            body_too_large: valid_json_rejection_code(body_too_large.into())?,
37            content_type: valid_json_rejection_code(content_type.into())?,
38            syntax: valid_json_rejection_code(syntax.into())?,
39            data_shape: valid_json_rejection_code(data_shape.into())?,
40        })
41    }
42
43    /// Returns the code for a body rejected because it is too large.
44    #[must_use]
45    pub fn body_too_large(&self) -> &str {
46        &self.body_too_large
47    }
48
49    /// Returns the code for a missing or invalid JSON content type.
50    #[must_use]
51    pub fn content_type(&self) -> &str {
52        &self.content_type
53    }
54
55    /// Returns the code for malformed JSON syntax.
56    #[must_use]
57    pub fn syntax(&self) -> &str {
58        &self.syntax
59    }
60
61    /// Returns the code for JSON that does not match the target data shape.
62    #[must_use]
63    pub fn data_shape(&self) -> &str {
64        &self.data_shape
65    }
66}
67
68impl Default for JsonRejectionCodes {
69    fn default() -> Self {
70        Self {
71            body_too_large: "payload_too_large".to_owned(),
72            content_type: "unsupported_media_type".to_owned(),
73            syntax: "invalid_json".to_owned(),
74            data_shape: "validation_failed".to_owned(),
75        }
76    }
77}
78
79/// Whether the HTTP layers add a default `Cache-Control` header to responses.
80#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
81pub enum ResponseCachePolicy {
82    /// Adds `Cache-Control: private, no-store` to every response that does not
83    /// already carry a `Cache-Control` header.
84    #[default]
85    PrivateNoStore,
86    /// Leaves `Cache-Control` entirely to handlers and outer layers.
87    HandlerOwned,
88}
89
90#[derive(Clone, Debug)]
91pub(crate) enum JsonRejectionMode {
92    Legacy(String),
93    Classified(JsonRejectionCodes),
94}
95
96/// Configuration for the shared HTTP layer stack.
97///
98/// CORS denies cross-origin access until explicit origins are supplied through
99/// [`HttpOptions::with_allowed_origins`]. Wildcard origins are rejected, which
100/// also prevents the invalid wildcard-with-credentials combination.
101#[derive(Clone, Debug)]
102pub struct HttpOptions {
103    /// Maximum time allowed for one request, including concurrency queueing.
104    pub request_timeout: Duration,
105    /// Maximum accepted request body size in bytes.
106    pub body_size_limit: usize,
107    /// Maximum number of requests processed concurrently.
108    pub concurrency_limit: usize,
109    /// Whether browsers may send credentials to explicitly allowed origins.
110    pub allow_credentials: bool,
111    pub(crate) allowed_origins: Vec<HeaderValue>,
112    pub(crate) additional_allowed_headers: Vec<HeaderName>,
113    pub(crate) additional_exposed_headers: Vec<HeaderName>,
114    pub(crate) response_cache_policy: ResponseCachePolicy,
115    pub(crate) json_rejection_mode: JsonRejectionMode,
116}
117
118impl HttpOptions {
119    /// Replaces the CORS origin allowlist.
120    ///
121    /// Each origin must be a valid HTTP header value and must not be `*`.
122    pub fn with_allowed_origins<I, S>(mut self, origins: I) -> Result<Self, HttpOptionsError>
123    where
124        I: IntoIterator<Item = S>,
125        S: AsRef<str>,
126    {
127        self.allowed_origins = parse_origins(origins)?;
128        Ok(self)
129    }
130
131    /// Returns the explicit CORS origin allowlist.
132    #[must_use]
133    pub fn allowed_origins(&self) -> &[HeaderValue] {
134        &self.allowed_origins
135    }
136
137    /// Adds request headers to the standard CORS allowlist.
138    ///
139    /// The built-in `Authorization`, `Content-Type`, `If-Match`, `Idempotency-Key`,
140    /// `x-request-id`, `traceparent`, and `tracestate` headers remain allowed. Duplicate header
141    /// names are ignored.
142    pub fn with_additional_allowed_headers<I, S>(
143        mut self,
144        headers: I,
145    ) -> Result<Self, HttpOptionsError>
146    where
147        I: IntoIterator<Item = S>,
148        S: AsRef<str>,
149    {
150        extend_header_names(&mut self.additional_allowed_headers, headers)?;
151        Ok(self)
152    }
153
154    /// Returns the request headers added to the standard CORS allowlist.
155    #[must_use]
156    pub fn additional_allowed_headers(&self) -> &[HeaderName] {
157        &self.additional_allowed_headers
158    }
159
160    /// Adds response headers to the standard CORS exposed set.
161    ///
162    /// The built-in `x-request-id`, `traceparent`, `tracestate`, `Retry-After`,
163    /// `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `ETag`, and
164    /// `Location` headers remain exposed. Duplicate header names are ignored.
165    pub fn with_additional_exposed_headers<I, S>(
166        mut self,
167        headers: I,
168    ) -> Result<Self, HttpOptionsError>
169    where
170        I: IntoIterator<Item = S>,
171        S: AsRef<str>,
172    {
173        extend_header_names(&mut self.additional_exposed_headers, headers)?;
174        Ok(self)
175    }
176
177    /// Returns the response headers added to the standard CORS exposed set.
178    #[must_use]
179    pub fn additional_exposed_headers(&self) -> &[HeaderName] {
180        &self.additional_exposed_headers
181    }
182
183    /// Replaces the response cache policy.
184    ///
185    /// The default is [`ResponseCachePolicy::PrivateNoStore`].
186    #[must_use]
187    pub const fn with_response_cache_policy(mut self, policy: ResponseCachePolicy) -> Self {
188        self.response_cache_policy = policy;
189        self
190    }
191
192    /// Returns the response cache policy.
193    #[must_use]
194    pub const fn response_cache_policy(&self) -> ResponseCachePolicy {
195        self.response_cache_policy
196    }
197
198    /// Overrides the error code returned when [`crate::ApiJson`] rejects a body.
199    ///
200    /// The default is `validation_failed`. The replacement must be a non-empty
201    /// snake_case identifier.
202    pub fn with_json_rejection_code(
203        mut self,
204        code: impl Into<String>,
205    ) -> Result<Self, HttpOptionsError> {
206        self.json_rejection_mode =
207            JsonRejectionMode::Legacy(valid_json_rejection_code(code.into())?);
208        Ok(self)
209    }
210
211    /// Enables class-specific JSON rejection statuses and codes.
212    ///
213    /// This preserves Axum's protocol statuses, including `413 Payload Too
214    /// Large`, `415 Unsupported Media Type`, and `422 Unprocessable Entity`.
215    #[must_use]
216    pub fn with_json_rejection_codes(mut self, codes: JsonRejectionCodes) -> Self {
217        self.json_rejection_mode = JsonRejectionMode::Classified(codes);
218        self
219    }
220
221    /// Returns the single JSON rejection code in compatibility mode.
222    ///
223    /// In class-specific mode this returns the data-shape code to preserve the
224    /// existing return type. Use [`HttpOptions::json_rejection_codes`] to read
225    /// all four codes.
226    #[must_use]
227    pub fn json_rejection_code(&self) -> &str {
228        match &self.json_rejection_mode {
229            JsonRejectionMode::Legacy(code) => code,
230            JsonRejectionMode::Classified(codes) => codes.data_shape(),
231        }
232    }
233
234    /// Returns the class-specific JSON rejection codes when that mode is active.
235    #[must_use]
236    pub fn json_rejection_codes(&self) -> Option<&JsonRejectionCodes> {
237        match &self.json_rejection_mode {
238            JsonRejectionMode::Legacy(_) => None,
239            JsonRejectionMode::Classified(codes) => Some(codes),
240        }
241    }
242
243    /// Builds options from Baukit's standard public-listener configuration.
244    ///
245    /// Listener address and port are intentionally ignored because they belong
246    /// to runtime composition rather than an Axum layer.
247    pub fn from_config(config: &HttpConfig) -> Result<Self, HttpOptionsError> {
248        Self {
249            request_timeout: config.request_timeout,
250            body_size_limit: config.body_size_limit,
251            concurrency_limit: config.concurrency_limit,
252            allow_credentials: false,
253            allowed_origins: parse_origins(&config.cors_allowed_origins)?,
254            additional_allowed_headers: Vec::new(),
255            additional_exposed_headers: Vec::new(),
256            response_cache_policy: ResponseCachePolicy::default(),
257            json_rejection_mode: JsonRejectionMode::Legacy("validation_failed".to_owned()),
258        }
259        .validate()
260    }
261
262    pub(crate) fn validate(self) -> Result<Self, HttpOptionsError> {
263        if self.request_timeout.is_zero() {
264            return Err(HttpOptionsError::ZeroRequestTimeout);
265        }
266        if self.body_size_limit == 0 {
267            return Err(HttpOptionsError::ZeroBodySizeLimit);
268        }
269        if self.concurrency_limit == 0 {
270            return Err(HttpOptionsError::ZeroConcurrencyLimit);
271        }
272        Ok(self)
273    }
274}
275
276impl Default for HttpOptions {
277    fn default() -> Self {
278        Self {
279            request_timeout: DEFAULT_REQUEST_TIMEOUT,
280            body_size_limit: DEFAULT_BODY_SIZE_LIMIT,
281            concurrency_limit: DEFAULT_CONCURRENCY_LIMIT,
282            allow_credentials: false,
283            allowed_origins: Vec::new(),
284            additional_allowed_headers: Vec::new(),
285            additional_exposed_headers: Vec::new(),
286            response_cache_policy: ResponseCachePolicy::default(),
287            json_rejection_mode: JsonRejectionMode::Legacy("validation_failed".to_owned()),
288        }
289    }
290}
291
292/// An invalid shared HTTP layer option.
293#[derive(Clone, Debug, Eq, PartialEq)]
294pub enum HttpOptionsError {
295    /// A CORS origin is not representable as an HTTP header value.
296    InvalidOrigin(String),
297    /// Wildcard CORS origins are deliberately unsupported.
298    WildcardOrigin,
299    /// A CORS allowed or exposed header name is invalid.
300    InvalidHeaderName(String),
301    /// The JSON rejection code is not a snake_case identifier.
302    InvalidJsonRejectionCode(String),
303    /// Request timeout must be non-zero.
304    ZeroRequestTimeout,
305    /// Body size limit must be non-zero.
306    ZeroBodySizeLimit,
307    /// Concurrency limit must be non-zero.
308    ZeroConcurrencyLimit,
309}
310
311impl fmt::Display for HttpOptionsError {
312    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
313        match self {
314            Self::InvalidOrigin(origin) => write!(formatter, "invalid CORS origin `{origin}`"),
315            Self::WildcardOrigin => formatter.write_str(
316                "wildcard CORS origins are unsupported; provide explicit allowed origins",
317            ),
318            Self::InvalidHeaderName(name) => {
319                write!(formatter, "invalid CORS header name `{name}`")
320            }
321            Self::InvalidJsonRejectionCode(code) => write!(
322                formatter,
323                "invalid JSON rejection error code `{code}`; expected snake_case"
324            ),
325            Self::ZeroRequestTimeout => formatter.write_str("request timeout must be non-zero"),
326            Self::ZeroBodySizeLimit => formatter.write_str("body size limit must be non-zero"),
327            Self::ZeroConcurrencyLimit => formatter.write_str("concurrency limit must be non-zero"),
328        }
329    }
330}
331
332impl std::error::Error for HttpOptionsError {}
333
334fn parse_origins<I, S>(origins: I) -> Result<Vec<HeaderValue>, HttpOptionsError>
335where
336    I: IntoIterator<Item = S>,
337    S: AsRef<str>,
338{
339    origins
340        .into_iter()
341        .map(|origin| {
342            let origin = origin.as_ref();
343            if origin == "*" {
344                return Err(HttpOptionsError::WildcardOrigin);
345            }
346            HeaderValue::from_str(origin)
347                .map_err(|_| HttpOptionsError::InvalidOrigin(origin.to_owned()))
348        })
349        .collect()
350}
351
352fn extend_header_names<I, S>(
353    names: &mut Vec<HeaderName>,
354    headers: I,
355) -> Result<(), HttpOptionsError>
356where
357    I: IntoIterator<Item = S>,
358    S: AsRef<str>,
359{
360    for header in headers {
361        let header = header.as_ref();
362        let parsed = HeaderName::from_bytes(header.as_bytes())
363            .map_err(|_| HttpOptionsError::InvalidHeaderName(header.to_owned()))?;
364        if !names.contains(&parsed) {
365            names.push(parsed);
366        }
367    }
368    Ok(())
369}
370
371fn valid_json_rejection_code(code: String) -> Result<String, HttpOptionsError> {
372    if is_valid_error_code(&code) {
373        Ok(code)
374    } else {
375        Err(HttpOptionsError::InvalidJsonRejectionCode(code))
376    }
377}