Skip to main content

litchee/api/gameplay/games/
export.rs

1//! Builders for the game-export endpoints (single game, a user's games,
2//! by-ids, bookmarks, and the current game).
3
4use futures_util::stream::BoxStream;
5use reqwest::Method;
6use reqwest::header::ACCEPT;
7use serde::Serialize;
8
9use super::model::LichessGame;
10use crate::client::LichessClient;
11use crate::config::Host;
12use crate::error::Result;
13use crate::http;
14use crate::http::{ACCEPT_JSON, ACCEPT_NDJSON, ACCEPT_PGN};
15use crate::model::GameExportOptions;
16
17/// Sort order for a games export.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "camelCase")]
20pub enum GameSort {
21    /// Oldest games first.
22    DateAsc,
23    /// Most recent games first (the server default).
24    DateDesc,
25}
26
27/// Builder for exporting a single game (`GET /game/export/{gameId}`).
28#[derive(Debug)]
29pub struct GameExportRequest<'a> {
30    client: &'a LichessClient,
31    game_id: &'a str,
32    filters: SingleGameFilters,
33    export: GameExportOptions,
34}
35
36/// The single-game filter that is not part of the shared export block.
37#[derive(Debug, Default, Serialize)]
38struct SingleGameFilters {
39    #[serde(rename = "withBookmarked", skip_serializing_if = "Option::is_none")]
40    with_bookmarked: Option<bool>,
41}
42
43impl<'a> GameExportRequest<'a> {
44    /// Creates the request builder.
45    pub(crate) fn new(client: &'a LichessClient, game_id: &'a str) -> Self {
46        Self {
47            client,
48            game_id,
49            filters: SingleGameFilters::default(),
50            export: GameExportOptions::default(),
51        }
52    }
53
54    /// Sets the shared export-format options (moves, clocks, evals, …).
55    #[must_use]
56    pub fn export(mut self, options: GameExportOptions) -> Self {
57        self.export = options;
58        self
59    }
60
61    /// Adds a `bookmarked` flag when the caller has bookmarked the game.
62    #[must_use]
63    pub fn with_bookmarked(mut self, value: bool) -> Self {
64        self.filters.with_bookmarked = Some(value);
65        self
66    }
67
68    /// Executes the export, returning the game as JSON.
69    pub async fn json(self) -> Result<LichessGame> {
70        let path = format!("/game/export/{}", http::segment(self.game_id));
71        let request = self
72            .client
73            .request(Method::GET, Host::Default, &path)
74            .header(ACCEPT, ACCEPT_JSON)
75            .query(&self.filters)
76            .query(&self.export);
77        http::json(request, "LichessGame").await
78    }
79
80    /// Executes the export, returning the game as a PGN string.
81    pub async fn pgn(self) -> Result<String> {
82        let path = format!("/game/export/{}", http::segment(self.game_id));
83        let request = self
84            .client
85            .request(Method::GET, Host::Default, &path)
86            .header(ACCEPT, ACCEPT_PGN)
87            .query(&self.filters)
88            .query(&self.export);
89        http::text(request).await
90    }
91}
92
93/// Builder for exporting a user's games (`GET /api/games/user/{username}`).
94#[derive(Debug)]
95pub struct UserGamesRequest<'a> {
96    client: &'a LichessClient,
97    username: &'a str,
98    query: UserGamesQuery<'a>,
99    export: GameExportOptions,
100}
101
102/// Filter parameters for exporting a user's games (the export-format flags live
103/// in the shared [`GameExportOptions`]).
104#[derive(Debug, Default, Serialize)]
105struct UserGamesQuery<'a> {
106    #[serde(skip_serializing_if = "Option::is_none")]
107    max: Option<u32>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    since: Option<i64>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    until: Option<i64>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    vs: Option<&'a str>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    rated: Option<bool>,
116    #[serde(rename = "perfType", skip_serializing_if = "Option::is_none")]
117    perf_type: Option<&'a str>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    color: Option<&'a str>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    analysed: Option<bool>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    ongoing: Option<bool>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    finished: Option<bool>,
126    #[serde(rename = "lastFen", skip_serializing_if = "Option::is_none")]
127    last_fen: Option<bool>,
128    #[serde(rename = "withBookmarked", skip_serializing_if = "Option::is_none")]
129    with_bookmarked: Option<bool>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    sort: Option<GameSort>,
132}
133
134impl<'a> UserGamesRequest<'a> {
135    /// Creates the request builder.
136    pub(crate) fn new(client: &'a LichessClient, username: &'a str) -> Self {
137        Self {
138            client,
139            username,
140            query: UserGamesQuery::default(),
141            export: GameExportOptions::default(),
142        }
143    }
144
145    /// Limits the number of games downloaded.
146    #[must_use]
147    pub fn max(mut self, count: u32) -> Self {
148        self.query.max = Some(count);
149        self
150    }
151
152    /// Only games played since this timestamp (Unix milliseconds).
153    #[must_use]
154    pub fn since(mut self, timestamp: i64) -> Self {
155        self.query.since = Some(timestamp);
156        self
157    }
158
159    /// Only games played until this timestamp (Unix milliseconds).
160    #[must_use]
161    pub fn until(mut self, timestamp: i64) -> Self {
162        self.query.until = Some(timestamp);
163        self
164    }
165
166    /// Only games played against this opponent.
167    #[must_use]
168    pub fn vs(mut self, opponent: &'a str) -> Self {
169        self.query.vs = Some(opponent);
170        self
171    }
172
173    /// Only rated (`true`) or casual (`false`) games.
174    #[must_use]
175    pub fn rated(mut self, rated: bool) -> Self {
176        self.query.rated = Some(rated);
177        self
178    }
179
180    /// Only games in these speeds/variants (comma-separated perf types).
181    #[must_use]
182    pub fn perf_type(mut self, perf_type: &'a str) -> Self {
183        self.query.perf_type = Some(perf_type);
184        self
185    }
186
187    /// Only games played as this color (`"white"` or `"black"`).
188    #[must_use]
189    pub fn color(mut self, color: &'a str) -> Self {
190        self.query.color = Some(color);
191        self
192    }
193
194    /// Only games with (`true`) or without (`false`) computer analysis.
195    #[must_use]
196    pub fn analysed(mut self, analysed: bool) -> Self {
197        self.query.analysed = Some(analysed);
198        self
199    }
200
201    /// Only currently-ongoing games.
202    #[must_use]
203    pub fn ongoing(mut self, ongoing: bool) -> Self {
204        self.query.ongoing = Some(ongoing);
205        self
206    }
207
208    /// Include finished games (`true`); set `false` to get only ongoing games.
209    #[must_use]
210    pub fn finished(mut self, finished: bool) -> Self {
211        self.query.finished = Some(finished);
212        self
213    }
214
215    /// Include the X-FEN of each game's last position (NDJSON only).
216    #[must_use]
217    pub fn last_fen(mut self, value: bool) -> Self {
218        self.query.last_fen = Some(value);
219        self
220    }
221
222    /// Add a `bookmarked` flag on games the caller has bookmarked (NDJSON only).
223    #[must_use]
224    pub fn with_bookmarked(mut self, value: bool) -> Self {
225        self.query.with_bookmarked = Some(value);
226        self
227    }
228
229    /// Sort order (defaults to most-recent-first).
230    #[must_use]
231    pub fn sort(mut self, sort: GameSort) -> Self {
232        self.query.sort = Some(sort);
233        self
234    }
235
236    /// Sets the shared export-format options (moves, clocks, evals, …).
237    #[must_use]
238    pub fn export(mut self, options: GameExportOptions) -> Self {
239        self.export = options;
240        self
241    }
242
243    /// Executes the export, streaming games as decoded JSON values.
244    pub async fn stream(self) -> Result<BoxStream<'static, Result<LichessGame>>> {
245        let path = format!("/api/games/user/{}", http::segment(self.username));
246        let request = self
247            .client
248            .request(Method::GET, Host::Default, &path)
249            .header(ACCEPT, ACCEPT_NDJSON)
250            .query(&self.query)
251            .query(&self.export);
252        http::stream(request, self.client.max_line_bytes()).await
253    }
254
255    /// Executes the export, returning all games as one PGN string.
256    pub async fn pgn(self) -> Result<String> {
257        let path = format!("/api/games/user/{}", http::segment(self.username));
258        let request = self
259            .client
260            .request(Method::GET, Host::Default, &path)
261            .header(ACCEPT, ACCEPT_PGN)
262            .query(&self.query)
263            .query(&self.export);
264        http::text(request).await
265    }
266}
267
268/// Builder for exporting several games by id (`POST /api/games/export/_ids`).
269#[derive(Debug)]
270pub struct ExportByIdsRequest<'a> {
271    client: &'a LichessClient,
272    ids: String,
273    export: GameExportOptions,
274}
275
276impl<'a> ExportByIdsRequest<'a> {
277    /// Creates the request builder from the game ids.
278    pub(crate) fn new(client: &'a LichessClient, ids: &[&str]) -> Self {
279        Self {
280            client,
281            ids: ids.join(","),
282            export: GameExportOptions::default(),
283        }
284    }
285
286    /// Sets the shared export-format options (moves, clocks, evals, …).
287    #[must_use]
288    pub fn export(mut self, options: GameExportOptions) -> Self {
289        self.export = options;
290        self
291    }
292
293    /// Executes the export, streaming games as decoded JSON values.
294    pub async fn stream(self) -> Result<BoxStream<'static, Result<LichessGame>>> {
295        let request = self
296            .client
297            .request(Method::POST, Host::Default, "/api/games/export/_ids")
298            .header(ACCEPT, ACCEPT_NDJSON)
299            .query(&self.export)
300            .text_body(self.ids);
301        http::stream(request, self.client.max_line_bytes()).await
302    }
303
304    /// Executes the export, returning all games as one PGN string.
305    pub async fn pgn(self) -> Result<String> {
306        let request = self
307            .client
308            .request(Method::POST, Host::Default, "/api/games/export/_ids")
309            .header(ACCEPT, ACCEPT_PGN)
310            .query(&self.export)
311            .text_body(self.ids);
312        http::text(request).await
313    }
314}
315
316/// Builder for exporting bookmarked games (`GET /api/games/export/bookmarks`).
317#[derive(Debug)]
318pub struct ExportBookmarksRequest<'a> {
319    client: &'a LichessClient,
320    query: BookmarksQuery,
321    export: GameExportOptions,
322}
323
324/// Filter parameters for the bookmarked-games export.
325#[derive(Debug, Default, Serialize)]
326struct BookmarksQuery {
327    #[serde(skip_serializing_if = "Option::is_none")]
328    since: Option<i64>,
329    #[serde(skip_serializing_if = "Option::is_none")]
330    until: Option<i64>,
331    #[serde(skip_serializing_if = "Option::is_none")]
332    max: Option<u32>,
333    #[serde(rename = "lastFen", skip_serializing_if = "Option::is_none")]
334    last_fen: Option<bool>,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    sort: Option<GameSort>,
337}
338
339impl<'a> ExportBookmarksRequest<'a> {
340    /// Creates the request builder.
341    pub(crate) fn new(client: &'a LichessClient) -> Self {
342        Self {
343            client,
344            query: BookmarksQuery::default(),
345            export: GameExportOptions::default(),
346        }
347    }
348
349    /// Only games played since this timestamp (Unix milliseconds).
350    #[must_use]
351    pub fn since(mut self, timestamp: i64) -> Self {
352        self.query.since = Some(timestamp);
353        self
354    }
355
356    /// Only games played until this timestamp (Unix milliseconds).
357    #[must_use]
358    pub fn until(mut self, timestamp: i64) -> Self {
359        self.query.until = Some(timestamp);
360        self
361    }
362
363    /// Limits the number of games downloaded.
364    #[must_use]
365    pub fn max(mut self, count: u32) -> Self {
366        self.query.max = Some(count);
367        self
368    }
369
370    /// Include the X-FEN of each game's last position.
371    #[must_use]
372    pub fn last_fen(mut self, value: bool) -> Self {
373        self.query.last_fen = Some(value);
374        self
375    }
376
377    /// Sort order (defaults to most-recent-first).
378    #[must_use]
379    pub fn sort(mut self, sort: GameSort) -> Self {
380        self.query.sort = Some(sort);
381        self
382    }
383
384    /// Sets the shared export-format options (moves, clocks, evals, …).
385    #[must_use]
386    pub fn export(mut self, options: GameExportOptions) -> Self {
387        self.export = options;
388        self
389    }
390
391    /// Executes the export, streaming games as decoded JSON values.
392    pub async fn stream(self) -> Result<BoxStream<'static, Result<LichessGame>>> {
393        let request = self
394            .client
395            .request(Method::GET, Host::Default, "/api/games/export/bookmarks")
396            .header(ACCEPT, ACCEPT_NDJSON)
397            .query(&self.query)
398            .query(&self.export);
399        http::stream(request, self.client.max_line_bytes()).await
400    }
401
402    /// Executes the export, returning all games as one PGN string.
403    pub async fn pgn(self) -> Result<String> {
404        let request = self
405            .client
406            .request(Method::GET, Host::Default, "/api/games/export/bookmarks")
407            .header(ACCEPT, ACCEPT_PGN)
408            .query(&self.query)
409            .query(&self.export);
410        http::text(request).await
411    }
412}
413
414/// Builder for exporting a user's current game
415/// (`GET /api/user/{username}/current-game`).
416#[derive(Debug)]
417pub struct CurrentGameRequest<'a> {
418    client: &'a LichessClient,
419    username: &'a str,
420    export: GameExportOptions,
421}
422
423impl<'a> CurrentGameRequest<'a> {
424    /// Creates the request builder.
425    pub(crate) fn new(client: &'a LichessClient, username: &'a str) -> Self {
426        Self {
427            client,
428            username,
429            export: GameExportOptions::default(),
430        }
431    }
432
433    /// Sets the shared export-format options (moves, clocks, evals, …).
434    #[must_use]
435    pub fn export(mut self, options: GameExportOptions) -> Self {
436        self.export = options;
437        self
438    }
439
440    /// Executes the export, returning the game as JSON.
441    pub async fn json(self) -> Result<LichessGame> {
442        let path = format!("/api/user/{}/current-game", http::segment(self.username));
443        let request = self
444            .client
445            .request(Method::GET, Host::Default, &path)
446            .header(ACCEPT, ACCEPT_JSON)
447            .query(&self.export);
448        http::json(request, "LichessGame").await
449    }
450
451    /// Executes the export, returning the game as a PGN string.
452    pub async fn pgn(self) -> Result<String> {
453        let path = format!("/api/user/{}/current-game", http::segment(self.username));
454        let request = self
455            .client
456            .request(Method::GET, Host::Default, &path)
457            .header(ACCEPT, ACCEPT_PGN)
458            .query(&self.export);
459        http::text(request).await
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn game_sort_uses_camel_case() {
469        assert_eq!(
470            serde_urlencoded::to_string([("sort", GameSort::DateAsc)]).unwrap(),
471            "sort=dateAsc"
472        );
473        assert_eq!(
474            serde_urlencoded::to_string([("sort", GameSort::DateDesc)]).unwrap(),
475            "sort=dateDesc"
476        );
477    }
478}