use futures_util::stream::BoxStream;
use reqwest::{Method, StatusCode};
use serde::{Deserialize, Serialize};
use crate::api::gameplay::games::LichessGame;
use crate::client::LichessClient;
use crate::config::Host;
use crate::error::{ApiErrorKind, LichessError, Result};
use crate::http;
use crate::model::LichessTitle;
fn map_unauthorized_edit(err: LichessError) -> LichessError {
match err {
LichessError::Api(api) if api.status == StatusCode::UNAUTHORIZED => {
LichessError::Api(api.with_kind(ApiErrorKind::SwissUnauthorizedEdit))
}
other => other,
}
}
#[derive(Debug, Serialize)]
struct CreateForm<'a> {
#[serde(rename = "clock.limit")]
clock_limit: u32,
#[serde(rename = "clock.increment")]
clock_increment: u32,
#[serde(rename = "nbRounds")]
nb_rounds: u32,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
rated: Option<bool>,
#[serde(rename = "roundInterval", skip_serializing_if = "Option::is_none")]
round_interval: Option<u32>,
}
#[derive(Debug)]
pub struct SwissApi<'a> {
client: &'a LichessClient,
}
impl<'a> SwissApi<'a> {
pub(crate) fn new(client: &'a LichessClient) -> Self {
Self { client }
}
pub async fn get(&self, id: &str) -> Result<LichessSwiss> {
let path = format!("/api/swiss/{}", http::segment(id));
let request = self.client.request(Method::GET, Host::Default, &path);
http::json(request, "LichessSwiss").await
}
#[must_use]
pub fn create(
&self,
team_id: &'a str,
clock_limit: u32,
clock_increment: u32,
nb_rounds: u32,
) -> CreateSwissRequest<'a> {
CreateSwissRequest::new(
self.client,
team_id,
false,
clock_limit,
clock_increment,
nb_rounds,
)
}
#[must_use]
pub fn edit(
&self,
id: &'a str,
clock_limit: u32,
clock_increment: u32,
nb_rounds: u32,
) -> CreateSwissRequest<'a> {
CreateSwissRequest::new(
self.client,
id,
true,
clock_limit,
clock_increment,
nb_rounds,
)
}
pub async fn join(&self, id: &str) -> Result<()> {
self.post_action(id, "join").await
}
pub async fn withdraw(&self, id: &str) -> Result<()> {
self.post_action(id, "withdraw").await
}
pub async fn terminate(&self, id: &str) -> Result<()> {
self.post_action(id, "terminate").await
}
pub async fn schedule_next_round(&self, id: &str) -> Result<()> {
self.post_action(id, "schedule-next-round")
.await
.map_err(map_unauthorized_edit)
}
pub async fn trf(&self, id: &str) -> Result<String> {
let path = format!("/swiss/{}.trf", http::segment(id));
http::text(self.client.request(Method::GET, Host::Default, &path)).await
}
pub async fn results(
&self,
id: &str,
) -> Result<BoxStream<'static, Result<LichessSwissResult>>> {
let path = format!("/api/swiss/{}/results", http::segment(id));
let request = self.client.request(Method::GET, Host::Default, &path);
http::stream(request, self.client.max_line_bytes()).await
}
pub async fn games(&self, id: &str) -> Result<BoxStream<'static, Result<LichessGame>>> {
let path = format!("/api/swiss/{}/games", http::segment(id));
let request = self
.client
.request(Method::GET, Host::Default, &path)
.header(reqwest::header::ACCEPT, "application/x-ndjson");
http::stream(request, self.client.max_line_bytes()).await
}
async fn post_action(&self, id: &str, action: &str) -> Result<()> {
let path = format!("/api/swiss/{}/{}", http::segment(id), http::segment(action));
http::ok(self.client.request(Method::POST, Host::Default, &path)).await
}
}
#[derive(Debug)]
pub struct CreateSwissRequest<'a> {
client: &'a LichessClient,
target_id: &'a str,
edit: bool,
form: CreateForm<'a>,
}
impl<'a> CreateSwissRequest<'a> {
fn new(
client: &'a LichessClient,
target_id: &'a str,
edit: bool,
clock_limit: u32,
clock_increment: u32,
nb_rounds: u32,
) -> Self {
Self {
client,
target_id,
edit,
form: CreateForm {
clock_limit,
clock_increment,
nb_rounds,
name: None,
rated: None,
round_interval: None,
},
}
}
#[must_use]
pub fn name(mut self, name: &'a str) -> Self {
self.form.name = Some(name);
self
}
#[must_use]
pub fn rated(mut self, rated: bool) -> Self {
self.form.rated = Some(rated);
self
}
#[must_use]
pub fn round_interval(mut self, seconds: u32) -> Self {
self.form.round_interval = Some(seconds);
self
}
pub async fn send(self) -> Result<LichessSwiss> {
let edit = self.edit;
let path = if edit {
format!("/api/swiss/{}/edit", http::segment(self.target_id))
} else {
format!("/api/swiss/new/{}", http::segment(self.target_id))
};
let request = self
.client
.request(Method::POST, Host::Default, &path)
.form(&self.form);
let result = http::json(request, "LichessSwiss").await;
if edit {
result.map_err(map_unauthorized_edit)
} else {
result
}
}
}
impl LichessClient {
#[must_use]
pub fn swiss(&self) -> SwissApi<'_> {
SwissApi::new(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessSwissClock {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub increment: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessSwissNextRound {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at: Option<i64>,
#[serde(rename = "in", default, skip_serializing_if = "Option::is_none")]
pub in_seconds: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessSwiss {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub starts_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clock: Option<LichessSwissClock>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub round: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nb_rounds: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nb_players: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nb_ongoing: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rated: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_round: Option<LichessSwissNextRound>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessSwissResult {
pub rank: u32,
pub points: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tie_break: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rating: Option<u32>,
pub username: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<LichessTitle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub performance: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ApiError;
#[test]
fn maps_401_to_swiss_unauthorized_edit() {
let err = LichessError::Api(ApiError::new(StatusCode::UNAUTHORIZED, None, None));
match map_unauthorized_edit(err) {
LichessError::Api(api) => assert_eq!(api.kind, ApiErrorKind::SwissUnauthorizedEdit),
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn leaves_non_401_errors_unchanged() {
let err = LichessError::Api(ApiError::new(StatusCode::NOT_FOUND, None, None));
match map_unauthorized_edit(err) {
LichessError::Api(api) => assert_eq!(api.kind, ApiErrorKind::NotFound),
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn parses_swiss_with_next_round() {
let json = r#"{"id":"abc","name":"Weekly","clock":{"limit":300,"increment":0},
"variant":"standard","round":2,"nbRounds":7,"nbPlayers":40,
"status":"started","rated":true,"nextRound":{"at":1700000000000,"in":120}}"#;
let swiss: LichessSwiss = serde_json::from_str(json).unwrap();
assert_eq!(swiss.nb_rounds, Some(7));
assert_eq!(swiss.next_round.unwrap().in_seconds, Some(120));
}
#[test]
fn parses_swiss_result_with_fractional_points() {
let json = r#"{"rank":1,"points":5.5,"tieBreak":18.0,"username":"A","rating":2400}"#;
let result: LichessSwissResult = serde_json::from_str(json).unwrap();
assert!((result.points - 5.5).abs() < f64::EPSILON);
}
}