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, Debug)]
80pub(crate) enum JsonRejectionMode {
81 Legacy(String),
82 Classified(JsonRejectionCodes),
83}
84
85#[derive(Clone, Debug)]
91pub struct HttpOptions {
92 pub request_timeout: Duration,
94 pub body_size_limit: usize,
96 pub concurrency_limit: usize,
98 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 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 #[must_use]
120 pub fn allowed_origins(&self) -> &[HeaderValue] {
121 &self.allowed_origins
122 }
123
124 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 #[must_use]
150 pub fn additional_allowed_headers(&self) -> &[HeaderName] {
151 &self.additional_allowed_headers
152 }
153
154 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 #[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 #[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 #[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 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#[derive(Clone, Debug, Eq, PartialEq)]
246pub enum HttpOptionsError {
247 InvalidOrigin(String),
249 WildcardOrigin,
251 InvalidHeaderName(String),
253 InvalidJsonRejectionCode(String),
255 ZeroRequestTimeout,
257 ZeroBodySizeLimit,
259 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}