use chrono::NaiveDate;
use crate::{BoardGameGeekApi, IntoQueryParam, ItemDomain, ItemSubType, Plays, QueryParam, Result};
#[derive(Clone, Debug, Default)]
pub struct PlaysQueryParams {
min_date: Option<NaiveDate>,
max_date: Option<NaiveDate>,
sub_type: Option<ItemSubType>,
page: Option<u64>,
}
impl PlaysQueryParams {
pub fn new() -> Self {
Self::default()
}
pub fn min_date(mut self, min_date: NaiveDate) -> Self {
self.min_date = Some(min_date);
self
}
pub fn max_date(mut self, max_date: NaiveDate) -> Self {
self.max_date = Some(max_date);
self
}
pub fn sub_type(mut self, sub_type: ItemSubType) -> Self {
self.sub_type = Some(sub_type);
self
}
pub fn page(mut self, page: u64) -> Self {
self.page = Some(page);
self
}
}
#[derive(Clone, Debug)]
enum PlaysQuery<'q> {
QueryByUser(&'q str),
QueryById {
id: u64,
plays_item_domain: ItemDomain,
},
}
#[derive(Clone, Debug)]
struct PlaysQueryBuilder<'builder> {
query: PlaysQuery<'builder>,
params: &'builder PlaysQueryParams,
}
impl<'builder> PlaysQueryBuilder<'builder> {
fn new(query: PlaysQuery<'builder>, params: &'builder PlaysQueryParams) -> Self {
Self { query, params }
}
fn build(self) -> Vec<QueryParam<'builder>> {
let mut query_params = vec![];
match self.query {
PlaysQuery::QueryByUser(username) => {
query_params.push(username.into_query_param("username"));
},
PlaysQuery::QueryById {
id,
plays_item_domain,
} => {
query_params.push(id.into_query_param("id"));
query_params.push(plays_item_domain.into_query_param("type"));
},
}
if let Some(min_date) = self.params.min_date {
query_params.push(min_date.into_query_param("mindate"));
}
if let Some(max_date) = self.params.max_date {
query_params.push(max_date.into_query_param("maxdate"));
}
if let Some(sub_type) = self.params.sub_type {
query_params.push(sub_type.into_query_param("subtype"));
}
if let Some(page) = self.params.page {
query_params.push(page.into_query_param("page"));
}
query_params
}
}
pub struct PlaysApi<'api> {
pub(crate) api: &'api BoardGameGeekApi,
endpoint: &'static str,
}
impl<'api> PlaysApi<'api> {
pub(crate) fn new(api: &'api BoardGameGeekApi) -> Self {
Self {
api,
endpoint: "plays",
}
}
pub async fn get_by_username(
&self,
username: &str,
query_params: &PlaysQueryParams,
) -> Result<Plays> {
let query = PlaysQueryBuilder::new(PlaysQuery::QueryByUser(username), query_params);
let request = self.api.build_request(self.endpoint, &query.build());
let response = self.api.execute_request::<Plays>(request).await?;
Ok(response)
}
pub async fn get_by_item_id(
&self,
item_id: u64,
query_params: &PlaysQueryParams,
) -> Result<Plays> {
let query = PlaysQueryBuilder::new(
PlaysQuery::QueryById {
id: item_id,
plays_item_domain: ItemDomain::Item,
},
query_params,
);
let request = self.api.build_request(self.endpoint, &query.build());
let response = self.api.execute_request::<Plays>(request).await?;
Ok(response)
}
pub async fn get_by_family_id(
&self,
family_id: u64,
query_params: &PlaysQueryParams,
) -> Result<Plays> {
let query = PlaysQueryBuilder::new(
PlaysQuery::QueryById {
id: family_id,
plays_item_domain: ItemDomain::Family,
},
query_params,
);
let request = self.api.build_request(self.endpoint, &query.build());
let response = self.api.execute_request::<Plays>(request).await?;
Ok(response)
}
}
#[cfg(test)]
mod tests {
use chrono::Duration;
use mockito::Matcher;
use super::*;
use crate::{Play, PlayedItem, Player};
#[tokio::test]
async fn get_by_username() {
let mut server = mockito::Server::new_async().await;
let api = BoardGameGeekApi {
base_url: server.url(),
client: reqwest::Client::new(),
};
let mock = server
.mock("GET", "/plays")
.match_query(Matcher::AllOf(vec![Matcher::UrlEncoded(
"username".to_owned(),
"BluebearBgg".to_owned(),
)]))
.with_status(200)
.with_body(
std::fs::read_to_string("test_data/plays/user_plays.xml")
.expect("failed to load test data"),
)
.create_async()
.await;
let plays = api
.plays()
.get_by_username("BluebearBgg", &PlaysQueryParams::new())
.await;
mock.assert_async().await;
assert!(plays.is_ok(), "error returned when okay expected");
let plays = plays.unwrap();
assert_eq!(
plays,
Plays {
username: "bluebearbgg".to_owned(),
user_id: 3_855_477,
total: 3,
page: 1,
plays: vec![
Play {
id: 113_391_260,
date: NaiveDate::from_ymd_opt(2026, 4, 30).unwrap(),
quantity: 1,
duration: Duration::minutes(60),
incomplete: false,
location: "kitchen".to_owned(),
do_not_count_win_stats: false,
comments: Some("blah".to_owned()),
played_item: PlayedItem {
name: "Lost Ruins of Arnak: The Missing Expedition".to_owned(),
id: 382_350,
sub_types: vec![
ItemSubType::BoardGame,
ItemSubType::BoardGameExpansion
],
},
players: vec![],
},
Play {
id: 112_947_972,
date: NaiveDate::from_ymd_opt(2026, 4, 18).unwrap(),
quantity: 2,
duration: Duration::minutes(1310),
incomplete: true,
location: "kitchen".to_owned(),
do_not_count_win_stats: false,
comments: None,
played_item: PlayedItem {
name: "Lost Ruins of Arnak".to_owned(),
id: 312_484,
sub_types: vec![ItemSubType::BoardGame],
},
players: vec![Player {
username: Some("BluebearBGG".to_owned()),
user_id: Some(3_855_477),
name: "Matthew Thompson".to_owned(),
start_position: "1".to_owned(),
color: "blue".to_owned(),
score: "999".to_owned(),
first_time_playing: true,
rating: 0,
won: true,
},],
},
Play {
id: 83_820_037,
date: NaiveDate::from_ymd_opt(2024, 4, 13).unwrap(),
quantity: 1,
duration: Duration::minutes(120),
incomplete: false,
location: "".to_owned(),
do_not_count_win_stats: false,
comments: Some(
"Fun game, first time playing. Played with 4 people.".to_owned()
),
played_item: PlayedItem {
name: "Lost Ruins of Arnak".to_owned(),
id: 312_484,
sub_types: vec![ItemSubType::BoardGame],
},
players: vec![Player {
username: Some("BluebearBGG".to_owned()),
user_id: Some(3_855_477),
name: "Matthew".to_owned(),
start_position: "".to_owned(),
color: "".to_owned(),
score: "".to_owned(),
first_time_playing: false,
rating: 0,
won: false,
},],
},
],
}
);
}
#[tokio::test]
async fn get_by_item_id() {
let mut server = mockito::Server::new_async().await;
let api = BoardGameGeekApi {
base_url: server.url(),
client: reqwest::Client::new(),
};
let mock = server
.mock("GET", "/plays")
.match_query(Matcher::AllOf(vec![
Matcher::UrlEncoded("id".to_owned(), "382350".to_owned()),
Matcher::UrlEncoded("type".to_owned(), "thing".to_owned()),
Matcher::UrlEncoded("mindate".to_owned(), "2026-01-01".to_owned()),
Matcher::UrlEncoded("maxdate".to_owned(), "2026-06-02".to_owned()),
Matcher::UrlEncoded("subtype".to_owned(), "boardgameexpansion".to_owned()),
Matcher::UrlEncoded("page".to_owned(), "1".to_owned()),
]))
.with_status(200)
.with_body(
std::fs::read_to_string("test_data/plays/thing_plays.xml")
.expect("failed to load test data"),
)
.create_async()
.await;
let params = PlaysQueryParams::new()
.min_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
.max_date(NaiveDate::from_ymd_opt(2026, 6, 2).unwrap())
.sub_type(ItemSubType::BoardGameExpansion)
.page(1);
let plays = api.plays().get_by_item_id(382_350, ¶ms).await;
mock.assert_async().await;
assert!(plays.is_ok(), "error returned when okay expected");
let plays = plays.unwrap();
assert_eq!(
plays,
Plays {
username: "".to_owned(),
user_id: 0,
total: 1,
page: 1,
plays: vec![Play {
id: 113_391_260,
date: NaiveDate::from_ymd_opt(2026, 4, 30).unwrap(),
quantity: 1,
duration: Duration::minutes(60),
incomplete: false,
location: "kitchen".to_owned(),
do_not_count_win_stats: false,
comments: Some("blah".to_owned()),
played_item: PlayedItem {
name: "Lost Ruins of Arnak: The Missing Expedition".to_owned(),
id: 382_350,
sub_types: vec![ItemSubType::BoardGame, ItemSubType::BoardGameExpansion],
},
players: vec![],
},],
}
);
}
}