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#[derive(Clone, Debug)]
80pub(crate) enum JsonRejectionMode {
81    Legacy(String),
82    Classified(JsonRejectionCodes),
83}
84
85/// Configuration for the shared HTTP layer stack.
86///
87/// CORS denies cross-origin access until explicit origins are supplied through
88/// [`HttpOptions::with_allowed_origins`]. Wildcard origins are rejected, which
89/// also prevents the invalid wildcard-with-credentials combination.
90#[derive(Clone, Debug)]
91pub struct HttpOptions {
92    /// Maximum time allowed for one request, including concurrency queueing.
93    pub request_timeout: Duration,
94    /// Maximum accepted request body size in bytes.
95    pub body_size_limit: usize,
96    /// Maximum number of requests processed concurrently.
97    pub concurrency_limit: usize,
98    /// Whether browsers may send credentials to explicitly allowed origins.
99    pub allow_credentials: bool,
100    pub(crate) allowed_origins: Vec<HeaderValue>,
101    pub(crate) additional_allowed_headers: Vec<HeaderName>,
102    pub(crate) json_rejection_mode: JsonRejectionMode,
103}
104
105impl HttpOptions {
106    /// Replaces the CORS origin allowlist.
107    ///
108    /// Each origin must be a valid HTTP header value and must not be `*`.
109    pub fn with_allowed_origins<I, S>(mut self, origins: I) -> Result<Self, HttpOptionsError>
110    where
111        I: IntoIterator<Item = S>,
112        S: AsRef<str>,
113    {
114        self.allowed_origins = parse_origins(origins)?;
115        Ok(self)
116    }
117
118    /// Returns the explicit CORS origin allowlist.
119    #[must_use]
120    pub fn allowed_origins(&self) -> &[HeaderValue] {
121        &self.allowed_origins
122    }
123
124    /// Adds request headers to the standard CORS allowlist.
125    ///
126    /// The built-in `Authorization`, `Content-Type`, `x-request-id`,
127    /// `traceparent`, and `tracestate` headers remain allowed. Duplicate header
128    /// names are ignored.
129    pub fn with_additional_allowed_headers<I, S>(
130        mut self,
131        headers: I,
132    ) -> Result<Self, HttpOptionsError>
133    where
134        I: IntoIterator<Item = S>,
135        S: AsRef<str>,
136    {
137        for header in headers {
138            let header = header.as_ref();
139            let parsed = HeaderName::from_bytes(header.as_bytes())
140                .map_err(|_| HttpOptionsError::InvalidHeaderName(header.to_owned()))?;
141            if !self.additional_allowed_headers.contains(&parsed) {
142                self.additional_allowed_headers.push(parsed);
143            }
144        }
145        Ok(self)
146    }
147
148    /// Returns the request headers added to the standard CORS allowlist.
149    #[must_use]
150    pub fn additional_allowed_headers(&self) -> &[HeaderName] {
151        &self.additional_allowed_headers
152    }
153
154    /// Overrides the error code returned when [`crate::ApiJson`] rejects a body.
155    ///
156    /// The default is `validation_failed`. The replacement must be a non-empty
157    /// snake_case identifier.
158    pub fn with_json_rejection_code(
159        mut self,
160        code: impl Into<String>,
161    ) -> Result<Self, HttpOptionsError> {
162        self.json_rejection_mode =
163            JsonRejectionMode::Legacy(valid_json_rejection_code(code.into())?);
164        Ok(self)
165    }
166
167    /// Enables class-specific JSON rejection statuses and codes.
168    ///
169    /// This preserves Axum's protocol statuses, including `413 Payload Too
170    /// Large`, `415 Unsupported Media Type`, and `422 Unprocessable Entity`.
171    #[must_use]
172    pub fn with_json_rejection_codes(mut self, codes: JsonRejectionCodes) -> Self {
173        self.json_rejection_mode = JsonRejectionMode::Classified(codes);
174        self
175    }
176
177    /// Returns the single JSON rejection code in compatibility mode.
178    ///
179    /// In class-specific mode this returns the data-shape code to preserve the
180    /// existing return type. Use [`HttpOptions::json_rejection_codes`] to read
181    /// all four codes.
182    #[must_use]
183    pub fn json_rejection_code(&self) -> &str {
184        match &self.json_rejection_mode {
185            JsonRejectionMode::Legacy(code) => code,
186            JsonRejectionMode::Classified(codes) => codes.data_shape(),
187        }
188    }
189
190    /// Returns the class-specific JSON rejection codes when that mode is active.
191    #[must_use]
192    pub fn json_rejection_codes(&self) -> Option<&JsonRejectionCodes> {
193        match &self.json_rejection_mode {
194            JsonRejectionMode::Legacy(_) => None,
195            JsonRejectionMode::Classified(codes) => Some(codes),
196        }
197    }
198
199    /// Builds options from Baukit's standard public-listener configuration.
200    ///
201    /// Listener address and port are intentionally ignored because they belong
202    /// to runtime composition rather than an Axum layer.
203    pub fn from_config(config: &HttpConfig) -> Result<Self, HttpOptionsError> {
204        Self {
205            request_timeout: config.request_timeout,
206            body_size_limit: config.body_size_limit,
207            concurrency_limit: config.concurrency_limit,
208            allow_credentials: false,
209            allowed_origins: parse_origins(&config.cors_allowed_origins)?,
210            additional_allowed_headers: Vec::new(),
211            json_rejection_mode: JsonRejectionMode::Legacy("validation_failed".to_owned()),
212        }
213        .validate()
214    }
215
216    pub(crate) fn validate(self) -> Result<Self, HttpOptionsError> {
217        if self.request_timeout.is_zero() {
218            return Err(HttpOptionsError::ZeroRequestTimeout);
219        }
220        if self.body_size_limit == 0 {
221            return Err(HttpOptionsError::ZeroBodySizeLimit);
222        }
223        if self.concurrency_limit == 0 {
224            return Err(HttpOptionsError::ZeroConcurrencyLimit);
225        }
226        Ok(self)
227    }
228}
229
230impl Default for HttpOptions {
231    fn default() -> Self {
232        Self {
233            request_timeout: DEFAULT_REQUEST_TIMEOUT,
234            body_size_limit: DEFAULT_BODY_SIZE_LIMIT,
235            concurrency_limit: DEFAULT_CONCURRENCY_LIMIT,
236            allow_credentials: false,
237            allowed_origins: Vec::new(),
238            additional_allowed_headers: Vec::new(),
239            json_rejection_mode: JsonRejectionMode::Legacy("validation_failed".to_owned()),
240        }
241    }
242}
243
244/// An invalid shared HTTP layer option.
245#[derive(Clone, Debug, Eq, PartialEq)]
246pub enum HttpOptionsError {
247    /// A CORS origin is not representable as an HTTP header value.
248    InvalidOrigin(String),
249    /// Wildcard CORS origins are deliberately unsupported.
250    WildcardOrigin,
251    /// A requested CORS header name is invalid.
252    InvalidHeaderName(String),
253    /// The JSON rejection code is not a snake_case identifier.
254    InvalidJsonRejectionCode(String),
255    /// Request timeout must be non-zero.
256    ZeroRequestTimeout,
257    /// Body size limit must be non-zero.
258    ZeroBodySizeLimit,
259    /// Concurrency limit must be non-zero.
260    ZeroConcurrencyLimit,
261}
262
263impl fmt::Display for HttpOptionsError {
264    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
265        match self {
266            Self::InvalidOrigin(origin) => write!(formatter, "invalid CORS origin `{origin}`"),
267            Self::WildcardOrigin => formatter.write_str(
268                "wildcard CORS origins are unsupported; provide explicit allowed origins",
269            ),
270            Self::InvalidHeaderName(name) => {
271                write!(formatter, "invalid CORS request header name `{name}`")
272            }
273            Self::InvalidJsonRejectionCode(code) => write!(
274                formatter,
275                "invalid JSON rejection error code `{code}`; expected snake_case"
276            ),
277            Self::ZeroRequestTimeout => formatter.write_str("request timeout must be non-zero"),
278            Self::ZeroBodySizeLimit => formatter.write_str("body size limit must be non-zero"),
279            Self::ZeroConcurrencyLimit => formatter.write_str("concurrency limit must be non-zero"),
280        }
281    }
282}
283
284impl std::error::Error for HttpOptionsError {}
285
286fn parse_origins<I, S>(origins: I) -> Result<Vec<HeaderValue>, HttpOptionsError>
287where
288    I: IntoIterator<Item = S>,
289    S: AsRef<str>,
290{
291    origins
292        .into_iter()
293        .map(|origin| {
294            let origin = origin.as_ref();
295            if origin == "*" {
296                return Err(HttpOptionsError::WildcardOrigin);
297            }
298            HeaderValue::from_str(origin)
299                .map_err(|_| HttpOptionsError::InvalidOrigin(origin.to_owned()))
300        })
301        .collect()
302}
303
304fn valid_json_rejection_code(code: String) -> Result<String, HttpOptionsError> {
305    if is_valid_error_code(&code) {
306        Ok(code)
307    } else {
308        Err(HttpOptionsError::InvalidJsonRejectionCode(code))
309    }
310}