use std::collections::HashMap;
use reqwest::Method;
use reqwest::header::{ACCEPT, CONTENT_TYPE};
use serde::{Deserialize, Serialize};
use crate::client::LichessClient;
use crate::config::Host;
use crate::error::Result;
use crate::http;
use crate::model::{LichessLightUser, LichessTitle, LichessUser, LichessUserExtended};
#[derive(Debug)]
pub struct UsersApi<'a> {
client: &'a LichessClient,
}
impl<'a> UsersApi<'a> {
pub(crate) fn new(client: &'a LichessClient) -> Self {
Self { client }
}
pub async fn get(&self, username: &str, query: &UserQuery) -> Result<LichessUserExtended> {
let path = format!("/api/user/{}", http::segment(username));
let request = self
.client
.request(Method::GET, Host::Default, &path)
.query(query);
http::json(request, "LichessUserExtended").await
}
pub async fn get_many(
&self,
ids: &[&str],
profile: Option<bool>,
rank: Option<bool>,
) -> Result<Vec<LichessUser>> {
let request = self
.client
.request(Method::POST, Host::Default, "/api/users")
.query(&[("profile", profile), ("rank", rank)])
.header(CONTENT_TYPE, "text/plain")
.body(ids.join(","));
http::json(request, "Vec<LichessUser>").await
}
pub async fn statuses(
&self,
ids: &[&str],
with_signal: Option<bool>,
with_game_ids: Option<bool>,
with_game_metas: Option<bool>,
) -> Result<Vec<LichessUserStatus>> {
let request = self
.client
.request(Method::GET, Host::Default, "/api/users/status")
.query(&[("ids", ids.join(","))])
.query(&[
("withSignal", with_signal),
("withGameIds", with_game_ids),
("withGameMetas", with_game_metas),
]);
http::json(request, "Vec<LichessUserStatus>").await
}
pub async fn crosstable(
&self,
user1: &str,
user2: &str,
matchup: bool,
) -> Result<LichessCrosstable> {
let path = format!(
"/api/crosstable/{}/{}",
http::segment(user1),
http::segment(user2)
);
let request = self
.client
.request(Method::GET, Host::Default, &path)
.query(&[("matchup", matchup)]);
http::json(request, "LichessCrosstable").await
}
#[must_use]
pub fn autocomplete(&self, term: &'a str) -> AutocompleteRequest<'a> {
AutocompleteRequest::new(self.client, term)
}
pub async fn rating_history(&self, username: &str) -> Result<Vec<LichessRatingHistoryEntry>> {
let path = format!("/api/user/{}/rating-history", http::segment(username));
let request = self.client.request(Method::GET, Host::Default, &path);
http::json(request, "Vec<LichessRatingHistoryEntry>").await
}
pub async fn perf_stats(&self, username: &str, perf: &str) -> Result<LichessPerfStat> {
let path = format!(
"/api/user/{}/perf/{}",
http::segment(username),
http::segment(perf)
);
let request = self.client.request(Method::GET, Host::Default, &path);
http::json(request, "LichessPerfStat").await
}
pub async fn activity(&self, username: &str) -> Result<Vec<LichessActivity>> {
let path = format!("/api/user/{}/activity", http::segment(username));
let request = self.client.request(Method::GET, Host::Default, &path);
http::json(request, "Vec<LichessActivity>").await
}
pub async fn leaderboards(&self) -> Result<HashMap<String, Vec<LichessTopUser>>> {
let request = self
.client
.request(Method::GET, Host::Default, "/api/player");
http::json(request, "leaderboards").await
}
pub async fn top(&self, perf: &str, nb: u32) -> Result<LichessLeaderboard> {
let path = format!("/api/player/top/{nb}/{}", http::segment(perf));
let request = self
.client
.request(Method::GET, Host::Default, &path)
.header(ACCEPT, "application/vnd.lichess.v3+json");
http::json(request, "LichessLeaderboard").await
}
pub async fn live_streamers(&self) -> Result<Vec<LichessLiveStreamer>> {
let request = self
.client
.request(Method::GET, Host::Default, "/api/streamer/live");
http::json(request, "Vec<LichessLiveStreamer>").await
}
pub async fn notes(&self, username: &str) -> Result<Vec<LichessUserNote>> {
let path = format!("/api/user/{}/note", http::segment(username));
let request = self.client.request(Method::GET, Host::Default, &path);
http::json(request, "Vec<LichessUserNote>").await
}
pub async fn write_note(&self, username: &str, text: &str) -> Result<()> {
let path = format!("/api/user/{}/note", http::segment(username));
let request = self
.client
.request(Method::POST, Host::Default, &path)
.form(&[("text", text)]);
http::ok(request).await
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct UserQuery {
#[serde(skip_serializing_if = "Option::is_none")]
trophies: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
profile: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
rank: Option<bool>,
#[serde(rename = "fideId", skip_serializing_if = "Option::is_none")]
fide_id: Option<bool>,
}
impl UserQuery {
#[must_use]
pub fn trophies(mut self, include: bool) -> Self {
self.trophies = Some(include);
self
}
#[must_use]
pub fn profile(mut self, include: bool) -> Self {
self.profile = Some(include);
self
}
#[must_use]
pub fn rank(mut self, include: bool) -> Self {
self.rank = Some(include);
self
}
#[must_use]
pub fn fide_id(mut self, include: bool) -> Self {
self.fide_id = Some(include);
self
}
}
#[derive(Debug, Default, Serialize)]
struct AutocompleteQuery<'a> {
term: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
names: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
friend: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
team: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
tour: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
swiss: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
teacher: Option<bool>,
}
#[derive(Debug)]
pub struct AutocompleteRequest<'a> {
client: &'a LichessClient,
query: AutocompleteQuery<'a>,
}
impl<'a> AutocompleteRequest<'a> {
pub(crate) fn new(client: &'a LichessClient, term: &'a str) -> Self {
Self {
client,
query: AutocompleteQuery {
term,
..Default::default()
},
}
}
#[must_use]
pub fn names(mut self, value: bool) -> Self {
self.query.names = Some(value);
self
}
#[must_use]
pub fn friend(mut self, value: bool) -> Self {
self.query.friend = Some(value);
self
}
#[must_use]
pub fn team(mut self, team_id: &'a str) -> Self {
self.query.team = Some(team_id);
self
}
#[must_use]
pub fn tour(mut self, tour_id: &'a str) -> Self {
self.query.tour = Some(tour_id);
self
}
#[must_use]
pub fn swiss(mut self, swiss_id: &'a str) -> Self {
self.query.swiss = Some(swiss_id);
self
}
#[must_use]
pub fn teacher(mut self, value: bool) -> Self {
self.query.teacher = Some(value);
self
}
pub async fn send(self) -> Result<Vec<String>> {
let request = self
.client
.request(Method::GET, Host::Default, "/api/player/autocomplete")
.query(&self.query);
http::json(request, "Vec<String>").await
}
}
impl LichessClient {
#[must_use]
pub fn users(&self) -> UsersApi<'_> {
UsersApi::new(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessUserStatus {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<LichessTitle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flair: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub online: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub playing: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub streaming: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub patron: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub patron_color: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signal: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub playing_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessCrosstable {
pub users: HashMap<String, f64>,
pub nb_games: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matchup: Option<LichessMatchup>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LichessMatchup {
pub users: HashMap<String, f64>,
pub nb_games: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_query_serializes_toggles() {
assert_eq!(
serde_urlencoded::to_string(UserQuery::default().trophies(true).fide_id(false))
.unwrap(),
"trophies=true&fideId=false"
);
assert_eq!(
serde_urlencoded::to_string(UserQuery::default()).unwrap(),
""
);
}
#[test]
fn autocomplete_query_serializes_filters() {
let query = AutocompleteQuery {
term: "bob",
names: Some(true),
team: Some("coders"),
..Default::default()
};
let encoded = serde_urlencoded::to_string(&query).unwrap();
assert_eq!(encoded, "term=bob&names=true&team=coders");
}
#[test]
fn parses_user_status_flags() {
let json = r#"{"id":"bobby","name":"Bobby","online":true,"playing":false}"#;
let status: LichessUserStatus = serde_json::from_str(json).unwrap();
assert_eq!(status.id, "bobby");
assert_eq!(status.online, Some(true));
assert_eq!(status.playing, Some(false));
assert_eq!(status.streaming, None);
}
#[test]
fn parses_crosstable_scores() {
let json = r#"{"users":{"neio":201.5,"thibault":144.5},"nbGames":346}"#;
let crosstable: LichessCrosstable = serde_json::from_str(json).unwrap();
assert_eq!(crosstable.nb_games, 346);
assert_eq!(crosstable.users.get("neio"), Some(&201.5));
assert!(crosstable.matchup.is_none());
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessRatingHistoryEntry {
pub name: String,
#[serde(default)]
pub points: Vec<[i32; 4]>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessUserNote {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<LichessLightUser>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<LichessLightUser>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub date: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessTopUserPerf {
pub rating: i32,
pub progress: i32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessTopUser {
pub id: String,
pub username: String,
#[serde(default)]
pub perfs: HashMap<String, LichessTopUserPerf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<LichessTitle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub patron_color: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub online: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessLeaderboard {
#[serde(default)]
pub users: Vec<LichessTopUser>,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessGlicko {
pub rating: f64,
pub deviation: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provisional: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessPerfStatPerf {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glicko: Option<LichessGlicko>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nb: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub progress: Option<i32>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessPerfStat {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub percentile: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rank: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<LichessLightUser>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perf: Option<LichessPerfStatPerf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessActivityInterval {
pub start: i64,
pub end: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessActivity {
pub interval: LichessActivityInterval,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessStreamDetails {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lang: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessLiveStreamer {
#[serde(flatten)]
pub user: LichessLightUser,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stream: Option<LichessStreamDetails>,
}
#[cfg(test)]
mod added_tests {
use super::*;
#[test]
fn parses_rating_history() {
let json = r#"[{"name":"Bullet","points":[[2011,0,8,1472],[2011,8,12,1314]]}]"#;
let history: Vec<LichessRatingHistoryEntry> = serde_json::from_str(json).unwrap();
assert_eq!(history[0].name, "Bullet");
assert_eq!(history[0].points[1], [2011, 8, 12, 1314]);
}
#[test]
fn parses_leaderboard_top_user() {
let json = r#"{"users":[{"id":"a","username":"A",
"perfs":{"bullet":{"rating":2900,"progress":5}},"title":"GM"}]}"#;
let board: LichessLeaderboard = serde_json::from_str(json).unwrap();
assert_eq!(board.users[0].perfs["bullet"].rating, 2900);
assert_eq!(board.users[0].title, Some(LichessTitle::Gm));
}
#[test]
fn parses_live_streamer_with_flattened_user() {
let json = r#"{"id":"a","name":"A","stream":{"service":"twitch","status":"Live!"}}"#;
let streamer: LichessLiveStreamer = serde_json::from_str(json).unwrap();
assert_eq!(streamer.user.id, "a");
assert_eq!(streamer.stream.unwrap().service.as_deref(), Some("twitch"));
}
}