use crate::model::OrderSide;
const CLIENT_REQUEST_ID_MAX_LEN: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum RequestValidationError {
#[error("request field `{field}` must not be empty")]
EmptyField {
field: &'static str,
},
#[error("request field `{field}` must be at most {max} characters")]
TooLong {
field: &'static str,
max: usize,
},
#[error("request field `{field}` must contain between {min} and {max} characters")]
LengthOutOfRange {
field: &'static str,
min: usize,
max: usize,
},
#[error("request field `{field}` has invalid format: {expected}")]
InvalidFormat {
field: &'static str,
expected: &'static str,
},
#[error("request field `{field}` must be between {min} and {max}")]
OutOfRange {
field: &'static str,
min: u64,
max: u64,
},
#[error("request field `{field}` must be between {min} and {max}")]
DecimalOutOfRange {
field: &'static str,
min: &'static str,
max: &'static str,
},
#[error("request field `{field}` is required when {condition}")]
RequiredWhen {
field: &'static str,
condition: &'static str,
},
#[error("request field `{field}` is not applicable when {condition}")]
NotApplicable {
field: &'static str,
condition: &'static str,
},
#[error("at least one of these request fields is required: {fields}")]
AtLeastOneRequired {
fields: &'static str,
},
#[error("request fields are mutually exclusive: {fields}")]
MutuallyExclusive {
fields: &'static str,
},
}
pub trait ValidateRequest {
fn validate(&self) -> Result<(), RequestValidationError>;
}
pub(crate) fn non_empty(field: &'static str, value: &str) -> Result<(), RequestValidationError> {
if value.trim().is_empty() {
return Err(RequestValidationError::EmptyField { field });
}
Ok(())
}
pub(crate) fn optional_non_empty(
field: &'static str,
value: Option<&str>,
) -> Result<(), RequestValidationError> {
if value.is_some_and(|value| value.trim().is_empty()) {
return Err(RequestValidationError::EmptyField { field });
}
Ok(())
}
pub(crate) fn require_when(
field: &'static str,
value: Option<&str>,
condition: &'static str,
) -> Result<(), RequestValidationError> {
match value {
Some(value) => non_empty(field, value),
None => Err(RequestValidationError::RequiredWhen { field, condition }),
}
}
pub(crate) fn reject_when_present<T>(
field: &'static str,
value: Option<&T>,
condition: &'static str,
) -> Result<(), RequestValidationError> {
if value.is_some() {
return Err(RequestValidationError::NotApplicable { field, condition });
}
Ok(())
}
pub(crate) fn max_length(
field: &'static str,
value: &str,
max: usize,
) -> Result<(), RequestValidationError> {
if value.chars().count() > max {
return Err(RequestValidationError::TooLong { field, max });
}
Ok(())
}
pub(crate) fn length_range(
field: &'static str,
value: &str,
min: usize,
max: usize,
) -> Result<(), RequestValidationError> {
let length = value.chars().count();
if !(min..=max).contains(&length) {
return Err(RequestValidationError::LengthOutOfRange { field, min, max });
}
Ok(())
}
pub(crate) fn range_u64(
field: &'static str,
value: u64,
min: u64,
max: u64,
) -> Result<(), RequestValidationError> {
if !(min..=max).contains(&value) {
return Err(RequestValidationError::OutOfRange { field, min, max });
}
Ok(())
}
pub(crate) fn one_of(
field: &'static str,
value: &str,
allowed: &[&str],
expected: &'static str,
) -> Result<(), RequestValidationError> {
non_empty(field, value)?;
if !allowed.contains(&value) {
return Err(RequestValidationError::InvalidFormat { field, expected });
}
Ok(())
}
pub(crate) fn optional_one_of(
field: &'static str,
value: Option<&str>,
allowed: &[&str],
expected: &'static str,
) -> Result<(), RequestValidationError> {
if let Some(value) = value {
one_of(field, value, allowed, expected)?;
}
Ok(())
}
pub(crate) fn unsigned_integer_string(
field: &'static str,
value: &str,
) -> Result<(), RequestValidationError> {
non_empty(field, value)?;
if !value.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(RequestValidationError::InvalidFormat {
field,
expected: "an unsigned integer encoded with ASCII digits",
});
}
Ok(())
}
pub(crate) fn optional_unsigned_integer_string(
field: &'static str,
value: Option<&str>,
) -> Result<(), RequestValidationError> {
if let Some(value) = value {
unsigned_integer_string(field, value)?;
}
Ok(())
}
pub(crate) fn positive_decimal_string(
field: &'static str,
value: &str,
) -> Result<(), RequestValidationError> {
non_empty(field, value)?;
let mut dot_seen = false;
let mut digit_seen = false;
let mut non_zero_seen = false;
for byte in value.bytes() {
match byte {
b'0'..=b'9' => {
digit_seen = true;
non_zero_seen |= byte != b'0';
}
b'.' if !dot_seen => dot_seen = true,
_ => {
return Err(RequestValidationError::InvalidFormat {
field,
expected: "a positive decimal string",
});
}
}
}
if !digit_seen || !non_zero_seen || value.starts_with('.') || value.ends_with('.') {
return Err(RequestValidationError::InvalidFormat {
field,
expected: "a positive decimal string",
});
}
Ok(())
}
pub(crate) fn non_negative_decimal_string(
field: &'static str,
value: &str,
) -> Result<(), RequestValidationError> {
non_empty(field, value)?;
let mut dot_seen = false;
let mut digit_seen = false;
for byte in value.bytes() {
match byte {
b'0'..=b'9' => digit_seen = true,
b'.' if !dot_seen => dot_seen = true,
_ => {
return Err(RequestValidationError::InvalidFormat {
field,
expected: "a non-negative decimal string",
});
}
}
}
if !digit_seen || value.starts_with('.') || value.ends_with('.') {
return Err(RequestValidationError::InvalidFormat {
field,
expected: "a non-negative decimal string",
});
}
Ok(())
}
pub(crate) fn positive_unsigned_integer_string(
field: &'static str,
value: &str,
) -> Result<(), RequestValidationError> {
unsigned_integer_string(field, value)?;
if value.bytes().all(|byte| byte == b'0') {
return Err(RequestValidationError::InvalidFormat {
field,
expected: "a positive integer encoded with ASCII digits",
});
}
Ok(())
}
pub(crate) fn optional_positive_decimal_string(
field: &'static str,
value: Option<&str>,
) -> Result<(), RequestValidationError> {
if let Some(value) = value {
positive_decimal_string(field, value)?;
}
Ok(())
}
pub(crate) fn decimal_string_range(
field: &'static str,
value: &str,
min: f64,
max: f64,
min_display: &'static str,
max_display: &'static str,
) -> Result<(), RequestValidationError> {
positive_decimal_string(field, value)?;
let parsed = value
.parse::<f64>()
.map_err(|_| RequestValidationError::InvalidFormat {
field,
expected: "a finite positive decimal string",
})?;
if !parsed.is_finite() || !(min..=max).contains(&parsed) {
return Err(RequestValidationError::DecimalOutOfRange {
field,
min: min_display,
max: max_display,
});
}
Ok(())
}
pub(crate) fn collection_length(
field: &'static str,
length: usize,
min: usize,
max: usize,
) -> Result<(), RequestValidationError> {
if !(min..=max).contains(&length) {
return Err(RequestValidationError::LengthOutOfRange { field, min, max });
}
Ok(())
}
pub(crate) fn non_empty_items<'a>(
field: &'static str,
values: impl IntoIterator<Item = &'a str>,
) -> Result<(), RequestValidationError> {
for value in values {
non_empty(field, value)?;
}
Ok(())
}
pub(crate) fn at_least_one(
fields: &'static str,
present: &[bool],
) -> Result<(), RequestValidationError> {
if !present.iter().copied().any(|present| present) {
return Err(RequestValidationError::AtLeastOneRequired { fields });
}
Ok(())
}
pub(crate) fn at_most_one(
fields: &'static str,
present: &[bool],
) -> Result<(), RequestValidationError> {
let count = present.iter().copied().filter(|present| *present).count();
if count > 1 {
return Err(RequestValidationError::MutuallyExclusive { fields });
}
Ok(())
}
pub(crate) fn exactly_one(
fields: &'static str,
present: &[bool],
) -> Result<(), RequestValidationError> {
at_least_one(fields, present)?;
at_most_one(fields, present)
}
pub(crate) fn validate_side(side: &OrderSide) -> Result<(), RequestValidationError> {
match side {
OrderSide::Buy | OrderSide::Sell => Ok(()),
_ => Err(RequestValidationError::InvalidFormat {
field: "side",
expected: "buy or sell",
}),
}
}
pub(crate) fn validate_client_request_id(
field: &'static str,
value: Option<&str>,
) -> Result<(), RequestValidationError> {
let Some(value) = value else {
return Ok(());
};
non_empty(field, value)?;
if value.chars().count() > CLIENT_REQUEST_ID_MAX_LEN {
return Err(RequestValidationError::TooLong {
field,
max: CLIENT_REQUEST_ID_MAX_LEN,
});
}
if !value.bytes().all(|byte| byte.is_ascii_alphanumeric()) {
return Err(RequestValidationError::InvalidFormat {
field,
expected: "1-32 ASCII alphanumeric characters",
});
}
Ok(())
}
#[cfg(test)]
mod tests;