use super::{ItemType, SearchResult, SearchResults};
use crate::{BoardGameGeekApi, IntoQueryParam, QueryParam, Result};
#[derive(Clone, Debug, Default)]
struct SearchQueryParams {
item_types: Vec<ItemType>,
exact: Option<bool>,
}
impl SearchQueryParams {
fn new() -> Self {
Self::default()
}
fn item_types(mut self, item_types: Vec<ItemType>) -> Self {
self.item_types.extend(item_types);
self
}
fn exact(mut self, exact: bool) -> Self {
self.exact = Some(exact);
self
}
}
#[derive(Clone, Debug)]
struct SearchQueryBuilder<'q> {
search_query: &'q str,
params: SearchQueryParams,
}
impl<'builder> SearchQueryBuilder<'builder> {
fn new(search_query: &'builder str, params: SearchQueryParams) -> Self {
Self {
search_query,
params,
}
}
fn build(self) -> Vec<QueryParam<'builder>> {
let mut query_params: Vec<_> = vec![];
query_params.push(self.search_query.into_query_param("query"));
if let Some(value) = self.params.exact {
query_params.push(value.into_query_param("exact"));
}
if self.params.item_types.is_empty() {
query_params.push(ItemType::BoardGame.into_query_param("type"));
} else {
query_params.push(self.params.item_types.into_query_param("type"));
}
query_params
}
}
pub struct SearchApi<'api> {
pub(crate) api: &'api BoardGameGeekApi,
endpoint: &'static str,
}
impl<'api> SearchApi<'api> {
pub(crate) fn new(api: &'api BoardGameGeekApi) -> Self {
Self {
api,
endpoint: "search",
}
}
pub async fn search_games(&self, query: &str) -> Result<Vec<SearchResult>> {
let query = SearchQueryBuilder::new(query, SearchQueryParams::new());
let request = self.api.build_request(self.endpoint, &query.build());
let response = self.api.execute_request::<SearchResults>(request).await?;
Ok(response.results)
}
pub async fn search_games_exact(&self, query: &str) -> Result<Vec<SearchResult>> {
let query = SearchQueryBuilder::new(query, SearchQueryParams::new().exact(true));
let request = self.api.build_request(self.endpoint, &query.build());
let response = self.api.execute_request::<SearchResults>(request).await?;
Ok(response.results)
}
pub async fn search(
&self,
query: &str,
item_types: Vec<ItemType>,
) -> Result<Vec<SearchResult>> {
let query = SearchQueryBuilder::new(query, SearchQueryParams::new().item_types(item_types));
let request = self.api.build_request(self.endpoint, &query.build());
let response = self.api.execute_request::<SearchResults>(request).await?;
Ok(response.results)
}
pub async fn search_exact(
&self,
query: &str,
item_types: Vec<ItemType>,
) -> Result<Vec<SearchResult>> {
let query = SearchQueryBuilder::new(
query,
SearchQueryParams::new().item_types(item_types).exact(true),
);
let request = self.api.build_request(self.endpoint, &query.build());
let response = self.api.execute_request::<SearchResults>(request).await?;
Ok(response.results)
}
}
#[cfg(test)]
mod tests {
use mockito::Matcher;
use super::*;
use crate::{ItemType, SearchResult};
#[tokio::test]
async fn search_games() {
let mut server = mockito::Server::new_async().await;
let api = BoardGameGeekApi {
base_url: server.url(),
client: reqwest::Client::new(),
};
let mock = server
.mock("GET", "/search")
.match_query(Matcher::AllOf(vec![Matcher::UrlEncoded(
"query".to_owned(),
"some search term".to_owned(),
)]))
.with_status(200)
.with_body(
std::fs::read_to_string("test_data/search/search.xml")
.expect("failed to load test data"),
)
.create_async()
.await;
let search_results = api.search().search_games("some search term").await;
mock.assert_async().await;
assert!(search_results.is_ok(), "error returned when okay expected");
let search_results = search_results.unwrap();
assert_eq!(search_results.len(), 2);
assert_eq!(
search_results[0],
SearchResult {
id: 312_484,
item_type: ItemType::BoardGame,
name: "Lost Ruins of Arnak".to_owned(),
year_published: Some(2020),
},
);
assert_eq!(
search_results[1],
SearchResult {
id: 341_254,
item_type: ItemType::BoardGameExpansion,
name: "Lost Ruins of Arnak: Expedition Leaders".to_owned(),
year_published: Some(2021),
},
);
}
#[tokio::test]
async fn search_games_exact() {
let mut server = mockito::Server::new_async().await;
let api = BoardGameGeekApi {
base_url: server.url(),
client: reqwest::Client::new(),
};
let mock = server
.mock("GET", "/search")
.match_query(Matcher::AllOf(vec![
Matcher::UrlEncoded("query".to_owned(), "lost ruins of arnak".to_owned()),
Matcher::UrlEncoded("exact".to_owned(), "1".to_owned()),
]))
.with_status(200)
.with_body(
std::fs::read_to_string("test_data/search/search_exact.xml")
.expect("failed to load test data"),
)
.create_async()
.await;
let search_results = api.search().search_games_exact("lost ruins of arnak").await;
mock.assert_async().await;
assert!(search_results.is_ok(), "error returned when okay expected");
let search_results = search_results.unwrap();
assert_eq!(search_results.len(), 1);
assert_eq!(
search_results[0],
SearchResult {
id: 312_484,
item_type: ItemType::BoardGame,
name: "Lost Ruins of Arnak".to_owned(),
year_published: Some(2020),
},
);
}
#[tokio::test]
async fn search_double_quotes() {
let mut server = mockito::Server::new_async().await;
let api = BoardGameGeekApi {
base_url: server.url(),
client: reqwest::Client::new(),
};
let mock = server
.mock("GET", "/search")
.match_query(Matcher::AllOf(vec![Matcher::UrlEncoded(
"query".to_owned(),
"a".to_owned(),
)]))
.with_status(200)
.with_body(
std::fs::read_to_string("test_data/search/search_result_quotes.xml")
.expect("failed to load test data"),
)
.create_async()
.await;
let search_results = api.search().search_games("a").await;
mock.assert_async().await;
assert!(search_results.is_ok(), "error returned when okay expected");
let search_results = search_results.unwrap();
assert_eq!(search_results.len(), 2);
assert_eq!(
search_results[0],
SearchResult {
id: 12668,
item_type: ItemType::BoardGame,
name: "\"Get Smart\"".to_owned(),
year_published: Some(1965),
},
);
assert_eq!(
search_results[1],
SearchResult {
id: 30346,
item_type: ItemType::BoardGame,
name: "\"Get Smart\" Card Game".to_owned(),
year_published: None,
},
);
}
#[tokio::test]
async fn search() {
let mut server = mockito::Server::new_async().await;
let api = BoardGameGeekApi {
base_url: server.url(),
client: reqwest::Client::new(),
};
let mock = server
.mock("GET", "/search")
.match_query(Matcher::AllOf(vec![
Matcher::UrlEncoded("query".to_owned(), "arnak".to_owned()),
Matcher::UrlEncoded("type".to_owned(), "boardgameexpansion".to_owned()),
]))
.with_status(200)
.with_body(
std::fs::read_to_string("test_data/search/search_expansions.xml")
.expect("failed to load test data"),
)
.create_async()
.await;
let search_results = api
.search()
.search("arnak", vec![ItemType::BoardGameExpansion])
.await;
mock.assert_async().await;
assert!(search_results.is_ok(), "error returned when okay expected");
let search_results = search_results.unwrap();
assert_eq!(search_results.len(), 1);
assert_eq!(
search_results[0],
SearchResult {
id: 341_254,
item_type: ItemType::BoardGameExpansion,
name: "Lost Ruins of Arnak: Expedition Leaders".to_owned(),
year_published: Some(2021),
},
);
}
#[tokio::test]
async fn search_exact() {
let mut server = mockito::Server::new_async().await;
let api = BoardGameGeekApi {
base_url: server.url(),
client: reqwest::Client::new(),
};
let mock = server
.mock("GET", "/search")
.match_query(Matcher::AllOf(vec![
Matcher::UrlEncoded("query".to_owned(), "lost ruins of arnak".to_owned()),
Matcher::UrlEncoded("exact".to_owned(), "1".to_owned()),
Matcher::UrlEncoded("type".to_owned(), "boardgame".to_owned()),
]))
.with_status(200)
.with_body(
std::fs::read_to_string("test_data/search/search_exact.xml")
.expect("failed to load test data"),
)
.create_async()
.await;
let search_results = api
.search()
.search_exact("lost ruins of arnak", vec![ItemType::BoardGame])
.await;
mock.assert_async().await;
assert!(search_results.is_ok(), "error returned when okay expected");
let search_results = search_results.unwrap();
assert_eq!(search_results.len(), 1);
assert_eq!(
search_results[0],
SearchResult {
id: 312_484,
item_type: ItemType::BoardGame,
name: "Lost Ruins of Arnak".to_owned(),
year_published: Some(2020),
},
);
}
#[tokio::test]
async fn search_multiple_types() {
let mut server = mockito::Server::new_async().await;
let api = BoardGameGeekApi {
base_url: server.url(),
client: reqwest::Client::new(),
};
let mock = server
.mock("GET", "/search")
.match_query(Matcher::AllOf(vec![
Matcher::UrlEncoded("query".to_owned(), "arnak".to_owned()),
Matcher::UrlEncoded(
"type".to_owned(),
"boardgame,boardgameaccessory,boardgameartist".to_owned(),
),
]))
.with_status(200)
.with_body(
std::fs::read_to_string("test_data/search/search_game_and_accessories.xml")
.expect("failed to load test data"),
)
.create_async()
.await;
let search_results = api
.search()
.search(
"arnak",
vec![
ItemType::BoardGame,
ItemType::BoardGameAccessory,
ItemType::BoardGameArtist,
],
)
.await;
mock.assert_async().await;
assert!(search_results.is_ok(), "error returned when okay expected");
let search_results = search_results.unwrap();
assert_eq!(search_results.len(), 2);
assert_eq!(
search_results[0],
SearchResult {
id: 403_238,
item_type: ItemType::BoardGameAccessory,
name: "Lost Ruins of Arnak + Expansions: The GiftForge Insert".to_owned(),
year_published: Some(2023),
},
);
assert_eq!(
search_results[1],
SearchResult {
id: 312_484,
item_type: ItemType::BoardGame,
name: "Lost Ruins of Arnak".to_owned(),
year_published: Some(2020),
},
);
}
}