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) -> Result<LichessUserExtended> {
let path = format!("/api/user/{}", http::segment(username));
let request = self.client.request(Method::GET, Host::Default, &path);
http::json(request, "LichessUserExtended").await
}
pub async fn get_many(&self, ids: &[&str]) -> Result<Vec<LichessUser>> {
let request = self
.client
.request(Method::POST, Host::Default, "/api/users")
.header(CONTENT_TYPE, "text/plain")
.body(ids.join(","));
http::json(request, "Vec<LichessUser>").await
}
pub async fn statuses(&self, ids: &[&str]) -> Result<Vec<LichessUserStatus>> {
let request = self
.client
.request(Method::GET, Host::Default, "/api/users/status")
.query(&[("ids", ids.join(","))]);
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
}
pub async fn autocomplete(&self, term: &str) -> Result<Vec<String>> {
let request = self
.client
.request(Method::GET, Host::Default, "/api/player/autocomplete")
.query(&[("term", term)]);
http::json(request, "Vec<String>").await
}
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
}
}
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 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"));
}
}