use std::{fmt, time::Duration};
use axum::http::{HeaderName, HeaderValue};
use baukit_config::HttpConfig;
use crate::error::is_valid_error_code;
const DEFAULT_BODY_SIZE_LIMIT: usize = 2 * 1024 * 1024;
const DEFAULT_CONCURRENCY_LIMIT: usize = 1_024;
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone, Debug)]
pub struct HttpOptions {
pub request_timeout: Duration,
pub body_size_limit: usize,
pub concurrency_limit: usize,
pub allow_credentials: bool,
pub(crate) allowed_origins: Vec<HeaderValue>,
pub(crate) additional_allowed_headers: Vec<HeaderName>,
pub(crate) json_rejection_code: String,
}
impl HttpOptions {
pub fn with_allowed_origins<I, S>(mut self, origins: I) -> Result<Self, HttpOptionsError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.allowed_origins = parse_origins(origins)?;
Ok(self)
}
#[must_use]
pub fn allowed_origins(&self) -> &[HeaderValue] {
&self.allowed_origins
}
pub fn with_additional_allowed_headers<I, S>(
mut self,
headers: I,
) -> Result<Self, HttpOptionsError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
for header in headers {
let header = header.as_ref();
let parsed = HeaderName::from_bytes(header.as_bytes())
.map_err(|_| HttpOptionsError::InvalidHeaderName(header.to_owned()))?;
if !self.additional_allowed_headers.contains(&parsed) {
self.additional_allowed_headers.push(parsed);
}
}
Ok(self)
}
#[must_use]
pub fn additional_allowed_headers(&self) -> &[HeaderName] {
&self.additional_allowed_headers
}
pub fn with_json_rejection_code(
mut self,
code: impl Into<String>,
) -> Result<Self, HttpOptionsError> {
let code = code.into();
if !is_valid_error_code(&code) {
return Err(HttpOptionsError::InvalidJsonRejectionCode(code));
}
self.json_rejection_code = code;
Ok(self)
}
#[must_use]
pub fn json_rejection_code(&self) -> &str {
&self.json_rejection_code
}
pub fn from_config(config: &HttpConfig) -> Result<Self, HttpOptionsError> {
Self {
request_timeout: config.request_timeout,
body_size_limit: config.body_size_limit,
concurrency_limit: config.concurrency_limit,
allow_credentials: false,
allowed_origins: parse_origins(&config.cors_allowed_origins)?,
additional_allowed_headers: Vec::new(),
json_rejection_code: "validation_failed".to_owned(),
}
.validate()
}
pub(crate) fn validate(self) -> Result<Self, HttpOptionsError> {
if self.request_timeout.is_zero() {
return Err(HttpOptionsError::ZeroRequestTimeout);
}
if self.body_size_limit == 0 {
return Err(HttpOptionsError::ZeroBodySizeLimit);
}
if self.concurrency_limit == 0 {
return Err(HttpOptionsError::ZeroConcurrencyLimit);
}
Ok(self)
}
}
impl Default for HttpOptions {
fn default() -> Self {
Self {
request_timeout: DEFAULT_REQUEST_TIMEOUT,
body_size_limit: DEFAULT_BODY_SIZE_LIMIT,
concurrency_limit: DEFAULT_CONCURRENCY_LIMIT,
allow_credentials: false,
allowed_origins: Vec::new(),
additional_allowed_headers: Vec::new(),
json_rejection_code: "validation_failed".to_owned(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum HttpOptionsError {
InvalidOrigin(String),
WildcardOrigin,
InvalidHeaderName(String),
InvalidJsonRejectionCode(String),
ZeroRequestTimeout,
ZeroBodySizeLimit,
ZeroConcurrencyLimit,
}
impl fmt::Display for HttpOptionsError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidOrigin(origin) => write!(formatter, "invalid CORS origin `{origin}`"),
Self::WildcardOrigin => formatter.write_str(
"wildcard CORS origins are unsupported; provide explicit allowed origins",
),
Self::InvalidHeaderName(name) => {
write!(formatter, "invalid CORS request header name `{name}`")
}
Self::InvalidJsonRejectionCode(code) => write!(
formatter,
"invalid JSON rejection error code `{code}`; expected snake_case"
),
Self::ZeroRequestTimeout => formatter.write_str("request timeout must be non-zero"),
Self::ZeroBodySizeLimit => formatter.write_str("body size limit must be non-zero"),
Self::ZeroConcurrencyLimit => formatter.write_str("concurrency limit must be non-zero"),
}
}
}
impl std::error::Error for HttpOptionsError {}
fn parse_origins<I, S>(origins: I) -> Result<Vec<HeaderValue>, HttpOptionsError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
origins
.into_iter()
.map(|origin| {
let origin = origin.as_ref();
if origin == "*" {
return Err(HttpOptionsError::WildcardOrigin);
}
HeaderValue::from_str(origin)
.map_err(|_| HttpOptionsError::InvalidOrigin(origin.to_owned()))
})
.collect()
}