use std::collections::HashMap;
use futures_util::stream::BoxStream;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::client::LichessClient;
use crate::config::Host;
use crate::error::Result;
use crate::http;
use crate::model::{LichessColor, LichessTitle};
#[derive(Debug)]
pub struct PuzzlesApi<'a> {
client: &'a LichessClient,
}
#[derive(Debug, Default, Serialize)]
struct NextQuery<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
angle: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
difficulty: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
color: Option<LichessColor>,
}
#[derive(Debug, Default, Serialize)]
struct ActivityQuery {
#[serde(skip_serializing_if = "Option::is_none")]
max: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
before: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
since: Option<i64>,
}
impl<'a> PuzzlesApi<'a> {
pub(crate) fn new(client: &'a LichessClient) -> Self {
Self { client }
}
pub async fn daily(&self) -> Result<LichessPuzzleAndGame> {
let request = self
.client
.request(Method::GET, Host::Default, "/api/puzzle/daily");
http::json(request, "LichessPuzzleAndGame").await
}
pub async fn get(&self, id: &str) -> Result<LichessPuzzleAndGame> {
let path = format!("/api/puzzle/{}", http::segment(id));
let request = self.client.request(Method::GET, Host::Default, &path);
http::json(request, "LichessPuzzleAndGame").await
}
pub async fn next(
&self,
angle: Option<&str>,
difficulty: Option<&str>,
color: Option<LichessColor>,
) -> Result<LichessPuzzleAndGame> {
let query = NextQuery {
angle,
difficulty,
color,
};
let request = self
.client
.request(Method::GET, Host::Default, "/api/puzzle/next")
.query(&query);
http::json(request, "LichessPuzzleAndGame").await
}
pub async fn activity(
&self,
max: Option<u32>,
before: Option<i64>,
since: Option<i64>,
) -> Result<BoxStream<'static, Result<LichessPuzzleActivity>>> {
let request = self
.client
.request(Method::GET, Host::Default, "/api/puzzle/activity")
.query(&ActivityQuery { max, before, since });
http::stream(request, self.client.max_line_bytes()).await
}
pub async fn batch(
&self,
angle: &str,
nb: u32,
difficulty: Option<&str>,
color: Option<LichessColor>,
) -> Result<LichessPuzzleBatch> {
let path = format!("/api/puzzle/batch/{}", http::segment(angle));
let request = self
.client
.request(Method::GET, Host::Default, &path)
.query(&[("nb", nb)])
.query(&[("difficulty", difficulty)])
.query(&[("color", color)]);
http::json(request, "LichessPuzzleBatch").await
}
pub async fn solve_batch(
&self,
angle: &str,
solutions: &[LichessPuzzleSolution],
nb: u32,
) -> Result<LichessPuzzleSolveResponse> {
let path = format!("/api/puzzle/batch/{}", http::segment(angle));
let body = serde_json::json!({ "solutions": solutions });
let request = self
.client
.request(Method::POST, Host::Default, &path)
.query(&[("nb", nb)])
.json(&body);
http::json(request, "LichessPuzzleSolveResponse").await
}
pub async fn dashboard(&self, days: u32) -> Result<LichessPuzzleDashboard> {
let path = format!("/api/puzzle/dashboard/{days}");
let request = self.client.request(Method::GET, Host::Default, &path);
http::json(request, "LichessPuzzleDashboard").await
}
pub async fn replay(&self, days: u32, theme: &str) -> Result<LichessPuzzleReplay> {
let path = format!("/api/puzzle/replay/{days}/{}", http::segment(theme));
let request = self.client.request(Method::GET, Host::Default, &path);
http::json(request, "LichessPuzzleReplay").await
}
pub async fn storm_dashboard(
&self,
username: &str,
days: Option<u32>,
) -> Result<LichessStormDashboard> {
let path = format!("/api/storm/dashboard/{}", http::segment(username));
let request = self
.client
.request(Method::GET, Host::Default, &path)
.query(&[("days", days)]);
http::json(request, "LichessStormDashboard").await
}
pub async fn racer(&self, id: &str) -> Result<LichessPuzzleRacer> {
let path = format!("/api/racer/{}", http::segment(id));
let request = self.client.request(Method::GET, Host::Default, &path);
http::json(request, "LichessPuzzleRacer").await
}
pub async fn create_racer(&self) -> Result<LichessPuzzleRacer> {
let request = self
.client
.request(Method::POST, Host::Default, "/api/racer");
http::json(request, "LichessPuzzleRacer").await
}
}
impl LichessClient {
#[must_use]
pub fn puzzles(&self) -> PuzzlesApi<'_> {
PuzzlesApi::new(self)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessPuzzlePerf {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessPuzzlePlayer {
pub color: LichessColor,
pub id: String,
pub name: String,
pub rating: u32,
#[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 patron_color: Option<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessPuzzleGame {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clock: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perf: Option<LichessPuzzlePerf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pgn: Option<String>,
#[serde(default)]
pub players: Vec<LichessPuzzlePlayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rated: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessPuzzle {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_ply: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plays: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rating: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fen: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_move: Option<String>,
#[serde(default)]
pub solution: Vec<String>,
#[serde(default)]
pub themes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessPuzzleAndGame {
pub game: LichessPuzzleGame,
pub puzzle: LichessPuzzle,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessPuzzleActivity {
pub date: i64,
pub puzzle: LichessPuzzle,
pub win: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn activity_query_omits_unset_fields() {
let query = ActivityQuery {
max: Some(10),
before: None,
since: Some(5),
};
assert_eq!(
serde_urlencoded::to_string(&query).unwrap(),
"max=10&since=5"
);
}
#[test]
fn parses_puzzle_and_game() {
let json = r#"{"game":{"id":"g","clock":"3+0",
"players":[{"color":"white","id":"a","name":"A","rating":1500},
{"color":"black","id":"b","name":"B","rating":1490}],"rated":true},
"puzzle":{"id":"p","initialPly":20,"plays":100,"rating":1600,
"solution":["e2e4"],"themes":["mateIn1"]}}"#;
let pg: LichessPuzzleAndGame = serde_json::from_str(json).unwrap();
assert_eq!(pg.puzzle.id, "p");
assert_eq!(pg.puzzle.solution, vec!["e2e4"]);
assert_eq!(pg.game.players.len(), 2);
}
#[test]
fn parses_activity_without_initial_ply() {
let json = r#"{"date":1623000000000,"win":true,
"puzzle":{"id":"p","fen":"x","lastMove":"e2e4","plays":1,"rating":1500,
"solution":["a1a2"],"themes":["short"]}}"#;
let activity: LichessPuzzleActivity = serde_json::from_str(json).unwrap();
assert!(activity.win);
assert_eq!(activity.puzzle.initial_ply, None);
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessPuzzleBatch {
#[serde(default)]
pub puzzles: Vec<LichessPuzzleAndGame>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glicko: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessPuzzleRound {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub win: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rating_diff: Option<i32>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessPuzzleSolveResponse {
#[serde(default)]
pub puzzles: Vec<LichessPuzzleAndGame>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glicko: Option<Value>,
#[serde(default)]
pub rounds: Vec<LichessPuzzleRound>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct LichessPuzzleSolution {
pub id: String,
pub win: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub rated: Option<bool>,
}
impl LichessPuzzleSolution {
#[must_use]
pub fn new(id: impl Into<String>, win: bool) -> Self {
Self {
id: id.into(),
win,
rated: None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessPuzzleDashboard {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub days: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub global: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub themes: Option<Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessStormDashboard {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub high: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub days: Option<Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessPuzzleReplay {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub angle: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replay: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessPuzzleRacer {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(flatten)]
pub other: HashMap<String, Value>,
}