use std::sync::Arc;
use serde::Serialize;
use crate::{
Result,
client::AnytypeClient,
config::MAX_PAGINATION_LIMIT,
filters::Query,
http_client::{GetPaged, HttpClient},
prelude::*,
};
#[derive(Debug, Default, Serialize)]
struct SearchRequestBody {
#[serde(skip_serializing_if = "Option::is_none")]
query: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
types: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sort: Option<Sort>,
#[serde(skip_serializing_if = "FilterExpression::is_empty")]
filters: FilterExpression,
}
#[derive(Debug)]
pub struct SearchRequest {
client: Arc<HttpClient>,
limit: Option<u32>,
offset: Option<u32>,
body: SearchRequestBody,
limits: ValidationLimits,
space_id: Option<String>,
}
impl SearchRequest {
pub(crate) fn new(
client: Arc<HttpClient>,
limits: ValidationLimits,
space_id: Option<String>,
) -> Self {
Self {
client,
limit: None,
offset: None,
body: SearchRequestBody::default(),
limits,
space_id,
}
}
#[must_use]
pub fn text(mut self, text: impl Into<String>) -> Self {
self.body.query = Some(text.into());
self
}
#[must_use]
pub fn limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
#[must_use]
pub fn offset(mut self, offset: u32) -> Self {
self.offset = Some(offset);
self
}
#[must_use]
pub fn types<S: Into<String>>(mut self, types: impl IntoIterator<Item = S>) -> Self {
self.body.types = types.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn sort_asc(mut self, property: impl Into<String>) -> Self {
self.body.sort = Some(Sort::asc(property));
self
}
#[must_use]
pub fn sort_desc(mut self, property: impl Into<String>) -> Self {
self.body.sort = Some(Sort::desc(property));
self
}
#[must_use]
pub fn filter(mut self, filter: Filter) -> Self {
self.body.filters = FilterExpression::from(vec![filter]);
self
}
#[must_use]
pub fn filters(mut self, filters: FilterExpression) -> Self {
self.body.filters = filters;
self
}
pub async fn execute(self) -> Result<PagedResult<Object>> {
if self
.limit
.is_some_and(|limit| limit == 0 || limit > MAX_PAGINATION_LIMIT)
{
return Err(AnytypeError::Validation {
message: format!("search limit must be between 1 and {MAX_PAGINATION_LIMIT}"),
});
}
let query = Query::default()
.set_limit_opt(self.limit)
.set_offset_opt(self.offset);
if let Some(space_id) = self.space_id {
self.limits.validate_id(&space_id, "space_id")?;
self.client
.post_request_paged(
&format!("/v1/spaces/{space_id}/search"),
&self.body,
query.into(),
)
.await
} else {
self.client
.post_request_paged("/v1/search", &self.body, query.into())
.await
}
}
}
impl AnytypeClient {
pub fn search_global(&self) -> SearchRequest {
SearchRequest::new(self.client.clone(), self.config.limits.clone(), None)
}
pub fn search_in(&self, space_id: impl Into<String>) -> SearchRequest {
SearchRequest::new(
self.client.clone(),
self.config.limits.clone(),
Some(space_id.into()),
)
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use tokio::net::TcpListener;
use super::*;
use crate::test_util::scripted_http::{
ScriptedHttpContentType, ScriptedHttpFixture, ScriptedHttpResponse,
};
const EMPTY_PAGE: &str =
r#"{"data":[],"pagination":{"has_more":false,"limit":25,"offset":3,"total":0}}"#;
fn scripted_client(address: std::net::SocketAddr) -> AnytypeClient {
let mut config = ClientConfig::default().app_name("search-wire-shape");
config.base_url = Some(format!("http://{address}"));
config.keystore = Some("env".to_owned());
let client = AnytypeClient::with_config(config).expect("create scripted search client");
client.set_api_key(HttpCredentials::new("fixture-token"));
client
}
async fn scripted_search_client() -> (AnytypeClient, ScriptedHttpFixture) {
let fixture = ScriptedHttpFixture::start(vec![ScriptedHttpResponse::new(
reqwest::StatusCode::OK,
ScriptedHttpContentType::Json,
EMPTY_PAGE,
)])
.await
.expect("start scripted search fixture");
let client = scripted_client(fixture.address());
(client, fixture)
}
fn sentinel_client(address: std::net::SocketAddr) -> AnytypeClient {
let mut config = ClientConfig::default().app_name("search-limit-validation");
config.base_url = Some(format!("http://{address}"));
config.keystore = Some("env".to_owned());
let client = AnytypeClient::with_config(config).expect("search limit sentinel client");
client.set_api_key(HttpCredentials::new("fixture-token"));
client
}
#[tokio::test]
async fn invalid_search_limits_fail_before_http_for_both_scopes() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind search limit sentinel");
let address = listener
.local_addr()
.expect("search limit sentinel address");
let client = sentinel_client(address);
let expected = format!("search limit must be between 1 and {MAX_PAGINATION_LIMIT}");
for limit in [0, MAX_PAGINATION_LIMIT + 1] {
for request in [client.search_global(), client.search_in("space-id")] {
let error = request
.limit(limit)
.execute()
.await
.expect_err("invalid search limit must fail");
assert!(
matches!(error, AnytypeError::Validation { ref message } if message == &expected)
);
}
}
assert_eq!(client.http_metrics().logical_operations, 0);
assert_eq!(client.http_metrics().physical_attempts, 0);
assert!(
tokio::time::timeout(Duration::from_millis(50), listener.accept())
.await
.is_err(),
"invalid search limits must not open a connection"
);
}
#[tokio::test]
async fn default_search_omits_body_fields_and_pagination_parameters() {
let (client, fixture) = scripted_search_client().await;
client
.search_global()
.execute()
.await
.expect("default scripted search response");
let requests = fixture.finish().await.expect("default search request");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method(), "POST");
assert_eq!(requests[0].path(), "/v1/search");
assert_eq!(requests[0].body(), b"{}");
}
#[tokio::test]
async fn search_forwards_nested_filters_sort_and_pagination_without_body_defaults() {
let (client, fixture) = scripted_search_client().await;
let filters = FilterExpression::and(
vec![Filter::text_contains("name", "draft")],
vec![FilterExpression::or(
vec![
Filter::select_in("status", ["open", "blocked"]),
Filter::number_greater("priority", 2),
],
Vec::new(),
)],
);
client
.search_global()
.text("quarterly plan")
.types(["page", "task"])
.sort_desc("last_modified_date")
.filters(filters)
.limit(25)
.offset(3)
.execute()
.await
.expect("scripted search response");
let requests = fixture.finish().await.expect("populated search request");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method(), "POST");
assert_eq!(requests[0].path(), "/v1/search?limit=25&offset=3");
assert_eq!(
serde_json::from_slice::<serde_json::Value>(requests[0].body())
.expect("search request body is JSON"),
serde_json::json!({
"query": "quarterly plan",
"types": ["page", "task"],
"sort": {"direction": "desc", "property_key": "last_modified_date"},
"filters": {
"conditions": [
{"condition": "contains", "property_key": "name", "text": "draft"}
],
"filters": [{
"conditions": [
{"condition": "in", "property_key": "status", "select": "open,blocked"},
{"condition": "gt", "property_key": "priority", "number": 2}
],
"operator": "or"
}],
"operator": "and"
}
})
);
}
#[tokio::test]
async fn malformed_space_id_fails_before_http() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind malformed space sentinel");
let address = listener
.local_addr()
.expect("malformed space sentinel address");
let client = sentinel_client(address);
let error = client
.search_in("")
.execute()
.await
.expect_err("empty space ID must fail validation");
assert!(matches!(error, AnytypeError::Validation { .. }));
assert_eq!(client.http_metrics().logical_operations, 0);
assert_eq!(client.http_metrics().physical_attempts, 0);
assert!(
tokio::time::timeout(Duration::from_millis(50), listener.accept())
.await
.is_err(),
"malformed space ID must not open a connection"
);
}
}