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, Eq, PartialEq)]
pub struct JsonRejectionCodes {
body_too_large: String,
content_type: String,
syntax: String,
data_shape: String,
}
impl JsonRejectionCodes {
pub fn new(
body_too_large: impl Into<String>,
content_type: impl Into<String>,
syntax: impl Into<String>,
data_shape: impl Into<String>,
) -> Result<Self, HttpOptionsError> {
Ok(Self {
body_too_large: valid_json_rejection_code(body_too_large.into())?,
content_type: valid_json_rejection_code(content_type.into())?,
syntax: valid_json_rejection_code(syntax.into())?,
data_shape: valid_json_rejection_code(data_shape.into())?,
})
}
#[must_use]
pub fn body_too_large(&self) -> &str {
&self.body_too_large
}
#[must_use]
pub fn content_type(&self) -> &str {
&self.content_type
}
#[must_use]
pub fn syntax(&self) -> &str {
&self.syntax
}
#[must_use]
pub fn data_shape(&self) -> &str {
&self.data_shape
}
}
impl Default for JsonRejectionCodes {
fn default() -> Self {
Self {
body_too_large: "payload_too_large".to_owned(),
content_type: "unsupported_media_type".to_owned(),
syntax: "invalid_json".to_owned(),
data_shape: "validation_failed".to_owned(),
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ResponseCachePolicy {
#[default]
PrivateNoStore,
HandlerOwned,
}
#[derive(Clone, Debug)]
pub(crate) enum JsonRejectionMode {
Legacy(String),
Classified(JsonRejectionCodes),
}
#[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) additional_exposed_headers: Vec<HeaderName>,
pub(crate) response_cache_policy: ResponseCachePolicy,
pub(crate) json_rejection_mode: JsonRejectionMode,
}
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>,
{
extend_header_names(&mut self.additional_allowed_headers, headers)?;
Ok(self)
}
#[must_use]
pub fn additional_allowed_headers(&self) -> &[HeaderName] {
&self.additional_allowed_headers
}
pub fn with_additional_exposed_headers<I, S>(
mut self,
headers: I,
) -> Result<Self, HttpOptionsError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
extend_header_names(&mut self.additional_exposed_headers, headers)?;
Ok(self)
}
#[must_use]
pub fn additional_exposed_headers(&self) -> &[HeaderName] {
&self.additional_exposed_headers
}
#[must_use]
pub const fn with_response_cache_policy(mut self, policy: ResponseCachePolicy) -> Self {
self.response_cache_policy = policy;
self
}
#[must_use]
pub const fn response_cache_policy(&self) -> ResponseCachePolicy {
self.response_cache_policy
}
pub fn with_json_rejection_code(
mut self,
code: impl Into<String>,
) -> Result<Self, HttpOptionsError> {
self.json_rejection_mode =
JsonRejectionMode::Legacy(valid_json_rejection_code(code.into())?);
Ok(self)
}
#[must_use]
pub fn with_json_rejection_codes(mut self, codes: JsonRejectionCodes) -> Self {
self.json_rejection_mode = JsonRejectionMode::Classified(codes);
self
}
#[must_use]
pub fn json_rejection_code(&self) -> &str {
match &self.json_rejection_mode {
JsonRejectionMode::Legacy(code) => code,
JsonRejectionMode::Classified(codes) => codes.data_shape(),
}
}
#[must_use]
pub fn json_rejection_codes(&self) -> Option<&JsonRejectionCodes> {
match &self.json_rejection_mode {
JsonRejectionMode::Legacy(_) => None,
JsonRejectionMode::Classified(codes) => Some(codes),
}
}
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(),
additional_exposed_headers: Vec::new(),
response_cache_policy: ResponseCachePolicy::default(),
json_rejection_mode: JsonRejectionMode::Legacy("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(),
additional_exposed_headers: Vec::new(),
response_cache_policy: ResponseCachePolicy::default(),
json_rejection_mode: JsonRejectionMode::Legacy("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 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()
}
fn extend_header_names<I, S>(
names: &mut Vec<HeaderName>,
headers: I,
) -> Result<(), 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 !names.contains(&parsed) {
names.push(parsed);
}
}
Ok(())
}
fn valid_json_rejection_code(code: String) -> Result<String, HttpOptionsError> {
if is_valid_error_code(&code) {
Ok(code)
} else {
Err(HttpOptionsError::InvalidJsonRejectionCode(code))
}
}