use futures_util::stream::BoxStream;
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::{LichessColor, LichessLightUser, LichessSpeed, LichessVariantKey};
const NDJSON: &str = "application/x-ndjson";
#[derive(Debug)]
pub struct GamesApi<'a> {
client: &'a LichessClient,
}
impl<'a> GamesApi<'a> {
pub(crate) fn new(client: &'a LichessClient) -> Self {
Self { client }
}
#[must_use]
pub fn export(&self, game_id: &'a str) -> GameExportRequest<'a> {
GameExportRequest::new(self.client, game_id)
}
#[must_use]
pub fn export_user(&self, username: &'a str) -> UserGamesRequest<'a> {
UserGamesRequest::new(self.client, username)
}
pub async fn current_game(&self, username: &str) -> Result<LichessGame> {
let path = format!("/api/user/{}/current-game", http::segment(username));
let request = self.client.request(Method::GET, Host::Default, &path);
http::json(request, "LichessGame").await
}
pub async fn import_game(&self, pgn: &str) -> Result<LichessImportedGame> {
let request = self
.client
.request(Method::POST, Host::Default, "/api/import")
.form(&[("pgn", pgn)]);
http::json(request, "LichessImportedGame").await
}
pub async fn now_playing(&self) -> Result<LichessNowPlaying> {
let request = self
.client
.request(Method::GET, Host::Default, "/api/account/playing");
http::json(request, "LichessNowPlaying").await
}
pub async fn chat(&self, game_id: &str) -> Result<Vec<LichessGameChatMessage>> {
let path = format!("/api/game/{}/chat", http::segment(game_id));
let request = self.client.request(Method::GET, Host::Default, &path);
http::json(request, "Vec<LichessGameChatMessage>").await
}
pub async fn export_by_ids(
&self,
ids: &[&str],
) -> Result<BoxStream<'static, Result<LichessGame>>> {
let request = self
.client
.request(Method::POST, Host::Default, "/api/games/export/_ids")
.header(ACCEPT, NDJSON)
.header(CONTENT_TYPE, "text/plain")
.body(ids.join(","));
http::stream(request, self.client.max_line_bytes()).await
}
pub async fn export_bookmarks(&self) -> Result<BoxStream<'static, Result<LichessGame>>> {
let request = self
.client
.request(Method::GET, Host::Default, "/api/games/export/bookmarks")
.header(ACCEPT, NDJSON);
http::stream(request, self.client.max_line_bytes()).await
}
pub async fn export_imports(&self) -> Result<BoxStream<'static, Result<LichessGame>>> {
let request = self
.client
.request(Method::GET, Host::Default, "/api/games/export/imports")
.header(ACCEPT, NDJSON);
http::stream(request, self.client.max_line_bytes()).await
}
pub async fn stream_moves(
&self,
game_id: &str,
) -> Result<BoxStream<'static, Result<LichessGameMoveUpdate>>> {
let path = format!("/api/stream/game/{}", http::segment(game_id));
let request = self.client.request(Method::GET, Host::Default, &path);
http::stream(request, self.client.max_line_bytes()).await
}
pub async fn stream_by_users(
&self,
usernames: &[&str],
) -> Result<BoxStream<'static, Result<LichessGame>>> {
let request = self
.client
.request(Method::POST, Host::Default, "/api/stream/games-by-users")
.header(CONTENT_TYPE, "text/plain")
.body(usernames.join(","));
http::stream(request, self.client.max_line_bytes()).await
}
pub async fn stream_by_ids(
&self,
stream_id: &str,
ids: &[&str],
) -> Result<BoxStream<'static, Result<LichessGame>>> {
let path = format!("/api/stream/games/{}", http::segment(stream_id));
let request = self
.client
.request(Method::POST, Host::Default, &path)
.header(CONTENT_TYPE, "text/plain")
.body(ids.join(","));
http::stream(request, self.client.max_line_bytes()).await
}
pub async fn add_to_stream(&self, stream_id: &str, ids: &[&str]) -> Result<()> {
let path = format!("/api/stream/games/{}/add", http::segment(stream_id));
let request = self
.client
.request(Method::POST, Host::Default, &path)
.header(CONTENT_TYPE, "text/plain")
.body(ids.join(","));
http::ok(request).await
}
}
impl LichessClient {
#[must_use]
pub fn games(&self) -> GamesApi<'_> {
GamesApi::new(self)
}
}
const PGN: &str = "application/x-chess-pgn";
#[derive(Debug)]
pub struct GameExportRequest<'a> {
client: &'a LichessClient,
game_id: &'a str,
query: SingleExportQuery,
}
#[derive(Debug, Default, Serialize)]
struct SingleExportQuery {
#[serde(skip_serializing_if = "Option::is_none")]
moves: Option<bool>,
#[serde(rename = "pgnInJson", skip_serializing_if = "Option::is_none")]
pgn_in_json: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
tags: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
clocks: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
evals: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
accuracy: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
opening: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
literate: Option<bool>,
}
impl<'a> GameExportRequest<'a> {
pub(crate) fn new(client: &'a LichessClient, game_id: &'a str) -> Self {
Self {
client,
game_id,
query: SingleExportQuery::default(),
}
}
#[must_use]
pub fn pgn_in_json(mut self, value: bool) -> Self {
self.query.pgn_in_json = Some(value);
self
}
#[must_use]
pub fn clocks(mut self, value: bool) -> Self {
self.query.clocks = Some(value);
self
}
#[must_use]
pub fn evals(mut self, value: bool) -> Self {
self.query.evals = Some(value);
self
}
#[must_use]
pub fn opening(mut self, value: bool) -> Self {
self.query.opening = Some(value);
self
}
#[must_use]
pub fn accuracy(mut self, value: bool) -> Self {
self.query.accuracy = Some(value);
self
}
pub async fn json(self) -> Result<LichessGame> {
let path = format!("/game/export/{}", http::segment(self.game_id));
let request = self
.client
.request(Method::GET, Host::Default, &path)
.query(&self.query);
http::json(request, "LichessGame").await
}
pub async fn pgn(self) -> Result<String> {
let path = format!("/game/export/{}", http::segment(self.game_id));
let request = self
.client
.request(Method::GET, Host::Default, &path)
.header(ACCEPT, PGN)
.query(&self.query);
http::text(request).await
}
}
#[derive(Debug)]
pub struct UserGamesRequest<'a> {
client: &'a LichessClient,
username: &'a str,
query: UserGamesQuery<'a>,
}
#[derive(Debug, Default, Serialize)]
struct UserGamesQuery<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
max: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
since: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
until: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
vs: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
rated: Option<bool>,
#[serde(rename = "perfType", skip_serializing_if = "Option::is_none")]
perf_type: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
color: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
analysed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
ongoing: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
finished: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
opening: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
evals: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
clocks: Option<bool>,
}
impl<'a> UserGamesRequest<'a> {
pub(crate) fn new(client: &'a LichessClient, username: &'a str) -> Self {
Self {
client,
username,
query: UserGamesQuery::default(),
}
}
#[must_use]
pub fn max(mut self, count: u32) -> Self {
self.query.max = Some(count);
self
}
#[must_use]
pub fn since(mut self, timestamp: i64) -> Self {
self.query.since = Some(timestamp);
self
}
#[must_use]
pub fn until(mut self, timestamp: i64) -> Self {
self.query.until = Some(timestamp);
self
}
#[must_use]
pub fn rated(mut self, rated: bool) -> Self {
self.query.rated = Some(rated);
self
}
#[must_use]
pub fn perf_type(mut self, perf_type: &'a str) -> Self {
self.query.perf_type = Some(perf_type);
self
}
#[must_use]
pub fn color(mut self, color: &'a str) -> Self {
self.query.color = Some(color);
self
}
#[must_use]
pub fn ongoing(mut self, ongoing: bool) -> Self {
self.query.ongoing = Some(ongoing);
self
}
pub async fn stream(self) -> Result<BoxStream<'static, Result<LichessGame>>> {
let path = format!("/api/games/user/{}", http::segment(self.username));
let request = self
.client
.request(Method::GET, Host::Default, &path)
.header(ACCEPT, NDJSON)
.query(&self.query);
http::stream(request, self.client.max_line_bytes()).await
}
pub async fn pgn(self) -> Result<String> {
let path = format!("/api/games/user/{}", http::segment(self.username));
let request = self
.client
.request(Method::GET, Host::Default, &path)
.header(ACCEPT, PGN)
.query(&self.query);
http::text(request).await
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum LichessGameStatusName {
Created,
Started,
Aborted,
Mate,
Resign,
Stalemate,
Timeout,
Draw,
Outoftime,
Cheat,
NoStart,
UnknownFinish,
InsufficientMaterialClaim,
VariantEnd,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessGameOpening {
pub eco: String,
pub name: String,
pub ply: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessMoveJudgment {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessGameMoveAnalysis {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub eval: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mate: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub best: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub judgment: Option<LichessMoveJudgment>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessPlayerAnalysis {
pub inaccuracy: u32,
pub mistake: u32,
pub blunder: u32,
pub acpl: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accuracy: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessGamePlayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<LichessLightUser>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rating: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rating_diff: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provisional: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ai_level: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub analysis: Option<LichessPlayerAnalysis>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub team: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessGamePlayers {
pub white: LichessGamePlayer,
pub black: LichessGamePlayer,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessGameClock {
pub initial: u32,
pub increment: u32,
pub total_time: u32,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessGameDivision {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub middle: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessGameArenaTour {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessGameSwissTour {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessGame {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rated: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant: Option<LichessVariantKey>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<LichessSpeed>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perf: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_move_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<LichessGameStatusName>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub players: Option<LichessGamePlayers>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_fen: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub winner: Option<LichessColor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opening: Option<LichessGameOpening>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub moves: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pgn: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub days_per_turn: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub analysis: Option<Vec<LichessGameMoveAnalysis>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arena_tour: Option<LichessGameArenaTour>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub swiss_tour: Option<LichessGameSwissTour>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clock: Option<LichessGameClock>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clocks: Option<Vec<u32>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub division: Option<LichessGameDivision>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessImportedGame {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_full_game() {
let json = r#"{
"id":"q7ZvsdUF","rated":true,"variant":"standard","speed":"blitz",
"perf":"blitz","createdAt":1514505150384,"status":"mate",
"winner":"white",
"players":{
"white":{"user":{"id":"a","name":"A"},"rating":1600,"ratingDiff":8},
"black":{"user":{"id":"b","name":"B"},"rating":1590,"ratingDiff":-8}
},
"opening":{"eco":"B01","name":"Scandinavian","ply":2},
"moves":"e4 d5 exd5",
"clock":{"initial":300,"increment":3,"totalTime":420}
}"#;
let game: LichessGame = serde_json::from_str(json).unwrap();
assert_eq!(game.id, "q7ZvsdUF");
assert_eq!(game.status, Some(LichessGameStatusName::Mate));
assert_eq!(game.winner, Some(LichessColor::White));
assert_eq!(game.players.unwrap().white.rating_diff, Some(8));
assert_eq!(game.clock.unwrap().total_time, 420);
}
#[test]
fn parses_minimal_game() {
let game: LichessGame = serde_json::from_str(r#"{"id":"abcd1234"}"#).unwrap();
assert_eq!(game.id, "abcd1234");
assert!(game.players.is_none());
}
#[test]
fn status_uses_camel_case_names() {
let game: LichessGame =
serde_json::from_str(r#"{"id":"x","status":"variantEnd"}"#).unwrap();
assert_eq!(game.status, Some(LichessGameStatusName::VariantEnd));
}
#[test]
fn parses_arena_and_swiss_tour_objects() {
let json = r#"{"id":"g","arenaTour":{"id":"abc","name":"Hourly"},
"swissTour":{"id":"xyz"}}"#;
let game: LichessGame = serde_json::from_str(json).unwrap();
let arena = game.arena_tour.unwrap();
assert_eq!(arena.id.as_deref(), Some("abc"));
assert_eq!(arena.name.as_deref(), Some("Hourly"));
assert_eq!(game.swiss_tour.unwrap().id.as_deref(), Some("xyz"));
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessNowPlayingOpponent {
pub id: String,
pub username: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rating: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rating_diff: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ai: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessNowPlayingGame {
pub game_id: String,
pub full_id: String,
pub color: LichessColor,
pub fen: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub has_moved: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_my_turn: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_move: Option<String>,
pub opponent: LichessNowPlayingOpponent,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perf: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rated: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seconds_left: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<LichessSpeed>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant: Option<LichessVariantKey>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tournament_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub swiss_id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessNowPlaying {
pub nb_my_turn: u32,
#[serde(default)]
pub now_playing: Vec<LichessNowPlayingGame>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessGameChatMessage {
pub user: String,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessGameMoveUpdate {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fen: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lm: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wc: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bc: Option<i64>,
#[serde(flatten)]
pub other: std::collections::HashMap<String, serde_json::Value>,
}