use serde::{Deserialize, Serialize};
use super::{SearchError, SearchFilter};
pub const MAX_QUERY_BYTES: usize = 8_192;
pub const DEFAULT_LIMIT: u32 = 10;
pub const MAX_LIMIT: u32 = 50;
pub const MIN_LIMIT: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SearchRequest {
pub query: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filter: Option<SearchFilter>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
#[derive(Debug, Clone)]
pub struct ValidatedRequest {
pub query: String,
pub filter: Option<SearchFilter>,
pub limit: u32,
}
impl SearchRequest {
pub fn validate(self) -> Result<ValidatedRequest, SearchError> {
let query = self.query.trim().to_string();
if query.is_empty() {
return Err(SearchError::invalid_input("query must not be blank"));
}
if query.len() > MAX_QUERY_BYTES {
return Err(SearchError::invalid_input(format!(
"query exceeds maximum length of {MAX_QUERY_BYTES} bytes"
)));
}
let limit = match self.limit {
None => DEFAULT_LIMIT,
Some(0) => {
return Err(SearchError::invalid_input(
"limit must be at least 1; use 'null' or omit to use the default",
));
}
Some(n) => n.min(MAX_LIMIT),
};
Ok(ValidatedRequest {
query,
filter: self.filter,
limit,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn req(query: &str) -> SearchRequest {
SearchRequest {
query: query.into(),
filter: None,
limit: None,
}
}
#[test]
fn valid_request_passes() {
let r = req("install cargo").validate();
assert!(r.is_ok());
let v = r.unwrap();
assert_eq!(v.query, "install cargo");
assert_eq!(v.limit, DEFAULT_LIMIT);
assert!(v.filter.is_none());
}
#[test]
fn empty_query_rejected() {
assert!(matches!(
req("").validate(),
Err(SearchError::InvalidInput { .. })
));
}
#[test]
fn whitespace_only_query_rejected() {
assert!(matches!(
req(" ").validate(),
Err(SearchError::InvalidInput { .. })
));
}
#[test]
fn query_trimmed_before_validation() {
let v = req(" hello ").validate().unwrap();
assert_eq!(v.query, "hello");
}
#[test]
fn query_exactly_8192_bytes_accepted() {
let q = "a".repeat(8192);
assert!(req(&q).validate().is_ok());
}
#[test]
fn query_8193_bytes_rejected() {
let q = "a".repeat(8193);
assert!(matches!(
req(&q).validate(),
Err(SearchError::InvalidInput { .. })
));
}
#[test]
fn an_unknown_top_level_key_is_refused_and_named() {
let err = serde_json::from_str::<SearchRequest>(
r#"{"query":"x","filters":{"mime":"text/markdown"}}"#,
)
.unwrap_err()
.to_string();
assert!(
err.contains("unknown field `filters`"),
"names the key: {err}"
);
for accepted in ["`query`", "`filter`", "`limit`"] {
assert!(err.contains(accepted), "lists {accepted}: {err}");
}
}
#[test]
fn an_unknown_filter_key_is_refused_and_named() {
let err =
serde_json::from_str::<SearchRequest>(r#"{"query":"x","filter":{"mimetype":"a/b"}}"#)
.unwrap_err()
.to_string();
assert!(err.contains("mimetype"), "names the key: {err}");
}
#[test]
fn limit_none_defaults_to_10() {
let v = SearchRequest {
query: "x".into(),
filter: None,
limit: None,
}
.validate()
.unwrap();
assert_eq!(v.limit, 10);
}
#[test]
fn limit_zero_rejected() {
let r = SearchRequest {
query: "x".into(),
filter: None,
limit: Some(0),
}
.validate();
assert!(matches!(r, Err(SearchError::InvalidInput { .. })));
}
#[test]
fn limit_999_clamped_to_50() {
let v = SearchRequest {
query: "x".into(),
filter: None,
limit: Some(999),
}
.validate()
.unwrap();
assert_eq!(v.limit, 50);
}
#[test]
fn limit_50_accepted() {
let v = SearchRequest {
query: "x".into(),
filter: None,
limit: Some(50),
}
.validate()
.unwrap();
assert_eq!(v.limit, 50);
}
#[test]
fn limit_1_accepted() {
let v = SearchRequest {
query: "x".into(),
filter: None,
limit: Some(1),
}
.validate()
.unwrap();
assert_eq!(v.limit, 1);
}
#[test]
fn serde_minimal_deserialization() {
let r: SearchRequest = serde_json::from_str(r#"{"query":"hello"}"#).unwrap();
assert_eq!(r.query, "hello");
assert!(r.filter.is_none());
assert!(r.limit.is_none());
}
#[test]
fn serde_negative_limit_fails_deserialization() {
let r: Result<SearchRequest, _> = serde_json::from_str(r#"{"query":"hello","limit":-1}"#);
assert!(r.is_err());
}
#[test]
fn serde_full_request_round_trip() {
use super::super::SearchFilter;
let original = SearchRequest {
query: "test query".into(),
filter: Some(SearchFilter {
mime: Some("text/markdown".into()),
..Default::default()
}),
limit: Some(5),
};
let json = serde_json::to_string(&original).unwrap();
let back: SearchRequest = serde_json::from_str(&json).unwrap();
assert_eq!(back.query, "test query");
assert_eq!(back.limit, Some(5));
}
}