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#[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 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 #[must_use]
45 pub fn body_too_large(&self) -> &str {
46 &self.body_too_large
47 }
48
49 #[must_use]
51 pub fn content_type(&self) -> &str {
52 &self.content_type
53 }
54
55 #[must_use]
57 pub fn syntax(&self) -> &str {
58 &self.syntax
59 }
60
61 #[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, Copy, Debug, Default, Eq, PartialEq)]
81pub enum ResponseCachePolicy {
82 #[default]
85 PrivateNoStore,
86 HandlerOwned,
88}
89
90#[derive(Clone, Debug)]
91pub(crate) enum JsonRejectionMode {
92 Legacy(String),
93 Classified(JsonRejectionCodes),
94}
95
96#[derive(Clone, Debug)]
102pub struct HttpOptions {
103 pub request_timeout: Duration,
105 pub body_size_limit: usize,
107 pub concurrency_limit: usize,
109 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 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 #[must_use]
133 pub fn allowed_origins(&self) -> &[HeaderValue] {
134 &self.allowed_origins
135 }
136
137 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 #[must_use]
156 pub fn additional_allowed_headers(&self) -> &[HeaderName] {
157 &self.additional_allowed_headers
158 }
159
160 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 #[must_use]
179 pub fn additional_exposed_headers(&self) -> &[HeaderName] {
180 &self.additional_exposed_headers
181 }
182
183 #[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 #[must_use]
194 pub const fn response_cache_policy(&self) -> ResponseCachePolicy {
195 self.response_cache_policy
196 }
197
198 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 #[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 #[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 #[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 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#[derive(Clone, Debug, Eq, PartialEq)]
294pub enum HttpOptionsError {
295 InvalidOrigin(String),
297 WildcardOrigin,
299 InvalidHeaderName(String),
301 InvalidJsonRejectionCode(String),
303 ZeroRequestTimeout,
305 ZeroBodySizeLimit,
307 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}