Skip to content

obazin/litchee

Repository files navigation

litchee

License: MIT Rust 2024 Lichess API

litchee is an asynchronous, builder-pattern Rust client for the Lichess API, covering every documented operation (184/184 of the official OpenAPI spec) — from looking up players to playing games, running tournaments, streaming live broadcasts, and "Log in with Lichess" via OAuth2 + PKCE.

It is open source (MIT) and aims at feature parity with the official API, with an ergonomic, strongly-typed surface: every DTO is prefixed Lichess*, every failure maps to a specific LichessError variant, and every endpoint is reached through a small accessor on the client (client.account(), client.broadcasts(), …).

use futures_util::StreamExt;
use litchee::LichessClient;

#[tokio::main]
async fn main() -> litchee::Result<()> {
    let client = LichessClient::builder().token("lip_your_token").build()?;

    // A simple JSON request.
    let me = client.account().profile().await?;
    println!("Logged in as {}", me.user.username);

    // A streaming (NDJSON) request.
    let mut games = client.games().export_user("bobby").max(5).stream().await?;
    while let Some(game) = games.next().await {
        println!("game {}", game?.id);
    }
    Ok(())
}

Why litchee?

The Lichess API is large (24 tags, ~184 operations, four hosts, JSON + NDJSON + PGN). litchee wraps all of it behind one cohesive, async-first client so Rust applications — bots, analysis tools, "Log in with Lichess" web apps, dataset exporters — can talk to Lichess without hand-rolling HTTP, streaming, and OAuth.

  • Complete. All 184 documented operations are implemented and covered by tests.
  • Async & streaming-native. Built on tokio + reqwest; NDJSON endpoints (event streams, board/bot game state, game exports) return a Stream you can consume directly.
  • Typed end to end. Lichess* DTOs and an exhaustive, matchable error type.
  • "Log in with Lichess". First-class OAuth2 Authorization Code flow with PKCE, plus plain personal access tokens.
  • Organized by business concern. The module tree mirrors the API's own structure, grouped into categories under litchee::api.

Installation

[dependencies]
litchee = "0.1"
tokio = { version = "1", features = ["full"] }
futures-util = "0.3" # to consume streams with `.next()`

The minimum supported Rust version is 1.95 (edition 2024).

Examples

1. Log in with Lichess (OAuth2 + PKCE)

New to OAuth or PKCE? The PKCE flow guide walks through the whole "Log in with Lichess" flow step by step, for beginners — with a glossary of OAuth terms at the end.

use litchee::LichessClient;
use litchee::api::auth::oauth::{AuthorizationRequest, CodeExchange, Scope};

# async fn run() -> litchee::Result<()> {
let client = LichessClient::new();

// 1. Build the authorization URL; persist `state` and `verifier` in the session.
let auth = client.oauth().authorization_url(&AuthorizationRequest {
    client_id: "your.app",
    redirect_uri: "https://your.app/callback",
    scopes: &[Scope::PreferenceRead, Scope::PuzzleRead, Scope::StudyRead],
    username_hint: None,
})?;
println!("Send the user to: {}", auth.url);

// 2. After the redirect, check `state`, then exchange the returned `code`.
let token = client.oauth().exchange_code(&CodeExchange {
    code: "code_from_redirect",
    code_verifier: &auth.verifier,
    redirect_uri: "https://your.app/callback",
    client_id: "your.app",
}).await?;

// 3. Build an authenticated client with the new token.
let user = LichessClient::builder().token(token.access_token.into_inner()).build()?;
println!("Hello, {}", user.account().profile().await?.user.username);
# Ok(())
# }

2. Export an authenticated user's games (NDJSON stream)

use futures_util::StreamExt;
use litchee::LichessClient;

# async fn run() -> litchee::Result<()> {
let client = LichessClient::builder().token("lip_your_token").build()?;
let me = client.account().profile().await?;

// Stream this user's last 20 rated blitz games as decoded JSON.
let mut games = client
    .games()
    .export_user(&me.user.username)
    .max(20)
    .rated(true)
    .perf_type("blitz")
    .stream()
    .await?;

while let Some(game) = games.next().await {
    let game = game?;
    println!("{} — winner: {:?}", game.id, game.winner);
}
# Ok(())
# }

3. A user's played puzzles

use futures_util::StreamExt;
use litchee::LichessClient;

# async fn run() -> litchee::Result<()> {
let client = LichessClient::builder().token("lip_your_token").build()?;

// Stream the authenticated user's puzzle history (needs the `puzzle:read` scope).
let mut activity = client.puzzles().activity(Some(50)).await?;
while let Some(round) = activity.next().await {
    let round = round?;
    let outcome = if round.win { "solved" } else { "failed" };
    println!("puzzle {} — {outcome}", round.puzzle.id);
}
# Ok(())
# }

4. Studies (list + export PGN)

use futures_util::StreamExt;
use litchee::LichessClient;

# async fn run() -> litchee::Result<()> {
let client = LichessClient::builder().token("lip_your_token").build()?;

// List a user's studies, then export the first one as PGN.
let mut studies = client.studies().list_metadata("bobby").await?;
if let Some(study) = studies.next().await {
    let study = study?;
    let pgn = client.studies().export_study_pgn(&study.id).await?;
    println!("{} — {} bytes of PGN", study.name, pgn.len());
}
# Ok(())
# }

5. Broadcasting (browse + round PGN)

use futures_util::StreamExt;
use litchee::LichessClient;

# async fn run() -> litchee::Result<()> {
let client = LichessClient::new();

// Browse official broadcasts, then export a round's games as PGN.
let mut official = client.broadcasts().official().await?;
if let Some(broadcast) = official.next().await {
    let broadcast = broadcast?;
    println!("Broadcast: {}", broadcast.tour.name);
    if let Some(round) = broadcast.rounds.first() {
        let pgn = client.broadcasts().round_pgn(&round.id).await?;
        println!("Round '{}' — {} bytes of PGN", round.name, pgn.len());
    }
}
# Ok(())
# }

The examples/ directory contains runnable programs:

  • oauth_flow — the full "Log in with Lichess" flow end to end: PKCE authorization, opening the browser, catching the redirect on a tiny local listener, exchanging the code, then listing the signed-in user's recent games, puzzle attempts, and studies (cargo run --example oauth_flow).
  • profile — print the authenticated user's profile (LICHESS_TOKEN=lip_xxx cargo run --example profile).
  • tv_feed — stream the Lichess TV feed (cargo run --example tv_feed).

API coverage

Every documented Lichess operation is implemented. Endpoints are reached through an accessor method on LichessClient and live in a module under litchee::api, grouped by category. The tables below map each concern's endpoints to its module.

Auth

client.oauth()litchee::api::auth::oauth (4 endpoints)

GET /oauth, POST /api/token, DELETE /api/token, POST /api/token/test

Users

client.account()litchee::api::users::account (6 endpoints)

GET /api/account, GET /api/account/email, GET /api/account/preferences, GET /api/account/kid, POST /api/account/kid, GET /api/timeline

client.users()litchee::api::users::players (13 endpoints)

GET /api/user/{username}, POST /api/users, GET /api/users/status, GET /api/crosstable/{u1}/{u2}, GET /api/player/autocomplete, GET /api/user/{username}/rating-history, GET /api/user/{username}/perf/{perf}, GET /api/user/{username}/activity, GET /api/player, GET /api/player/top/{nb}/{perfType}, GET /api/streamer/live, GET /api/user/{username}/note, POST /api/user/{username}/note

client.fide()litchee::api::users::fide (3 endpoints)

GET /api/fide/player/{playerId}, GET /api/fide/player/{playerId}/ratings, GET /api/fide/player

Social

client.relations()litchee::api::social::relations (5 endpoints)

GET /api/rel/following, POST /api/rel/follow/{username}, POST /api/rel/unfollow/{username}, POST /api/rel/block/{username}, POST /api/rel/unblock/{username}

client.messaging()litchee::api::social::messaging (1 endpoint)

POST /inbox/{username}

client.teams()litchee::api::social::teams (14 endpoints)

GET /api/team/{teamId}, GET /api/team/all, GET /api/team/of/{username}, GET /api/team/search, GET /api/team/{teamId}/users, GET /api/team/{teamId}/requests, POST /api/team/{teamId}/request/{userId}/accept, POST /api/team/{teamId}/request/{userId}/decline, POST /api/team/{teamId}/kick/{userId}, POST /team/{teamId}/join, POST /team/{teamId}/quit, POST /team/{teamId}/pm-all, GET /api/team/{teamId}/arena, GET /api/team/{teamId}/swiss

Tournaments

client.arena()litchee::api::tournaments::arena (13 endpoints)

GET /api/tournament, GET /api/tournament/{id}, POST /api/tournament, POST /api/tournament/{id}, POST /api/tournament/team-battle/{id}, GET /api/tournament/{id}/teams, POST /api/tournament/{id}/join, POST /api/tournament/{id}/withdraw, POST /api/tournament/{id}/terminate, GET /api/tournament/{id}/results, GET /api/tournament/{id}/games, GET /api/user/{username}/tournament/created, GET /api/user/{username}/tournament/played

client.swiss()litchee::api::tournaments::swiss (10 endpoints)

GET /api/swiss/{id}, POST /api/swiss/new/{teamId}, POST /api/swiss/{id}/edit, POST /api/swiss/{id}/join, POST /api/swiss/{id}/withdraw, POST /api/swiss/{id}/terminate, POST /api/swiss/{id}/schedule-next-round, GET /swiss/{id}.trf, GET /api/swiss/{id}/results, GET /api/swiss/{id}/games

client.simuls()litchee::api::tournaments::simuls (1 endpoint)

GET /api/simul

Training

client.puzzles()litchee::api::training::puzzles (11 endpoints)

GET /api/puzzle/daily, GET /api/puzzle/{id}, GET /api/puzzle/next, GET /api/puzzle/activity, GET /api/puzzle/batch/{angle}, POST /api/puzzle/batch/{angle}, GET /api/puzzle/dashboard/{days}, GET /api/puzzle/replay/{days}/{theme}, GET /api/storm/dashboard/{username}, GET /api/racer/{id}, POST /api/racer

client.studies()litchee::api::training::studies (9 endpoints)

GET /api/study/{studyId}/{chapterId}.pgn, GET /api/study/{studyId}.pgn, GET /api/study/by/{username}/export.pgn, GET /api/study/by/{username}, POST /api/study, POST /api/study/{studyId}/import-pgn, POST /api/study/{studyId}/{chapterId}/moves, POST /api/study/{studyId}/{chapterId}/tags, DELETE /api/study/{studyId}/{chapterId}

Broadcasting

client.broadcasts()litchee::api::broadcasting::broadcasts (19 endpoints)

GET /api/broadcast, GET /api/broadcast/top, GET /api/broadcast/search, GET /api/broadcast/by/{username}, GET /api/broadcast/my-rounds, GET /api/broadcast/{id}, GET /api/broadcast/{tourSlug}/{roundSlug}/{roundId}, GET /api/broadcast/round/{roundId}.pgn, GET /api/broadcast/{id}.pgn, GET /api/stream/broadcast/round/{roundId}.pgn, POST /api/broadcast/round/{roundId}/push, POST /api/broadcast/round/{roundId}/reset, GET /broadcast/{id}/players, GET /broadcast/{id}/players/{playerId}, GET /broadcast/{id}/teams/standings, POST /broadcast/new, POST /broadcast/{id}/edit, POST /broadcast/{id}/new, POST /broadcast/round/{roundId}/edit

client.tv()litchee::api::broadcasting::tv (4 endpoints)

GET /api/tv/channels, GET /api/tv/feed, GET /api/tv/{channel}, GET /api/tv/{channel}/feed

Database (separate hosts: explorer / tablebase)

client.opening_explorer()litchee::api::database::opening_explorer (4 endpoints, explorer.lichess.org)

GET /masters, GET /lichess, GET /player, GET /masters/pgn/{gameId}

client.tablebase()litchee::api::database::tablebase (3 endpoints, tablebase.lichess.org)

GET /standard, GET /atomic, GET /antichess

client.analysis()litchee::api::database::analysis (1 endpoint)

GET /api/cloud-eval

Gameplay

client.board()litchee::api::gameplay::board (13 endpoints)

GET /api/stream/event, GET /api/board/game/stream/{gameId}, POST /api/board/game/{gameId}/move/{move}, POST /api/board/game/{gameId}/abort, POST /api/board/game/{gameId}/resign, POST /api/board/game/{gameId}/draw/{accept}, POST /api/board/game/{gameId}/takeback/{accept}, POST /api/board/game/{gameId}/claim-victory, POST /api/board/game/{gameId}/claim-draw, POST /api/board/game/{gameId}/berserk, GET /api/board/game/{gameId}/chat, POST /api/board/game/{gameId}/chat, POST /api/board/seek

client.bot()litchee::api::gameplay::bot (13 endpoints)

POST /api/bot/account/upgrade, GET /api/bot/online, GET /api/stream/event, GET /api/bot/game/stream/{gameId}, POST /api/bot/game/{gameId}/move/{move}, POST /api/bot/game/{gameId}/abort, POST /api/bot/game/{gameId}/resign, POST /api/bot/game/{gameId}/draw/{accept}, POST /api/bot/game/{gameId}/takeback/{accept}, POST /api/bot/game/{gameId}/claim-victory, POST /api/bot/game/{gameId}/claim-draw, GET /api/bot/game/{gameId}/chat, POST /api/bot/game/{gameId}/chat

client.challenges()litchee::api::gameplay::challenges (11 endpoints)

GET /api/challenge, GET /api/challenge/{challengeId}/show, POST /api/challenge/{username}, POST /api/challenge/ai, POST /api/challenge/open, POST /api/challenge/{challengeId}/accept, POST /api/challenge/{challengeId}/decline, POST /api/challenge/{challengeId}/cancel, POST /api/challenge/{gameId}/start-clocks, POST /api/round/{gameId}/add-time/{seconds}, POST /api/token/admin-challenge

client.bulk_pairing()litchee::api::gameplay::bulk_pairing (6 endpoints)

GET /api/bulk-pairing, POST /api/bulk-pairing, GET /api/bulk-pairing/{id}, DELETE /api/bulk-pairing/{id}, POST /api/bulk-pairing/{id}/start-clocks, GET /api/bulk-pairing/{id}/games

client.games()litchee::api::gameplay::games (13 endpoints)

GET /game/export/{gameId}, GET /api/games/user/{username}, POST /api/games/export/_ids, GET /api/games/export/bookmarks, GET /api/games/export/imports, GET /api/account/playing, GET /api/user/{username}/current-game, GET /game/{gameId}/chat, POST /api/import, GET /api/stream/game/{id}, POST /api/stream/games-by-users, POST /api/stream/games/{streamId}, POST /api/stream/games/{streamId}/add

Engine (work endpoints on `engine.lichess.ovh`)

client.external_engine()litchee::api::engine::external_engine (8 endpoints)

GET /api/external-engine, POST /api/external-engine, GET /api/external-engine/{id}, PUT /api/external-engine/{id}, DELETE /api/external-engine/{id}, POST /api/external-engine/{id}/analyse, POST /api/external-engine/work, POST /api/external-engine/work/{id}

The OAuth GET /oauth endpoint is not a request the client makes — it's the URL you redirect the user's browser to. client.oauth().authorization_url(…) builds it. GET /api/stream/event is shared by the Board and Bot APIs.

Design & technical choices

  • Async-first on tokio + reqwest (rustls). Async is required because many Lichess endpoints stream newline-delimited JSON (application/x-ndjson): event streams, board/bot game state, game/tournament exports, TV feeds.
  • Ergonomic streaming. Streaming endpoints return BoxStream<'static, Result<T>>Unpin, Send, and consumable directly with StreamExt::next(). Lines are buffered across network chunks and keep-alive blanks are skipped.
  • Exhaustive, matchable errors. Every failure maps to a specific LichessError variant: a structured ApiError (status → typed kind + body message + Retry-After), a typed OAuthError, transport/decode/stream failures, and PKCE validation errors.
  • Resilient by configuration. Connect/read timeouts are tunable on the builder (the read timeout defaults to 5 minutes so long-lived NDJSON streams aren't killed), the NDJSON line buffer is bounded as a DoS guard (max_line_bytes), and rate-limited (429) requests can be retried opt-in via RetryPolicy — it waits the response's Retry-After when present, otherwise exponential backoff clamped to a ceiling. Tokens are held in a Secret wrapper that redacts them from Debug output.
  • Builder pattern. The client (LichessClient::builder()) and every request with optional parameters (game export, challenges, tournaments, …) use builders rather than wide function signatures.
  • Four hosts, one client. lichess.org, explorer.lichess.org, tablebase.lichess.org, and engine.lichess.ovh are routed internally; each is overridable on the builder (for self-hosted lila, localhost, or mocks).
  • Strong, forward-compatible types. Every DTO is prefixed Lichess* and is #[non_exhaustive]. Where the API returns very large or evolving aggregates (e.g. perf stats, activity feeds, broadcast nested payloads), the documented fields are typed and the remainder is preserved losslessly in serde_json values — nothing is dropped.
  • Organized by business concern. The module tree mirrors the API's own organization, grouped into categories under src/api/ (see below). Core plumbing (client, config, error, http, model, stream) lives at the crate root.
  • Tested deterministically. Each endpoint has an integration test that runs against a wiremock mock server with fixtures derived from the spec's own examples; pure logic (PKCE derivation, NDJSON splitting, error mapping, serde round-trips) has unit tests. CI runs fmt, clippy -D warnings, the test suite, the doc build, and an MSRV check.
  • Safety & quality gates. #![forbid(unsafe_code)], clippy pedantic, missing_docs, and a self-imposed ≤900 LOC/file and ≤20 LOC/method limit.

Project layout

src/
  lib.rs
  client/ config/ error/ http/ model/ stream/   # core plumbing
  api/
    auth/          oauth
    users/         account, players, fide
    social/        relations, messaging, teams
    tournaments/   arena, swiss, simuls
    training/      puzzles, studies
    broadcasting/  broadcasts, tv
    database/      opening_explorer, tablebase, analysis
    gameplay/      board, bot, challenges, bulk_pairing, games
    engine/        external_engine

The vendored OpenAPI spec (reference/ submodule)

This repository includes the official Lichess OpenAPI specification as a git submodule at reference/lichess-api/ (source: lichess-org/api). It is the source of truth for the client and is used during development to:

  • Model DTOs faithfully — field names, optionality, and enums are taken directly from the spec's schemas.
  • Drive deterministic tests — integration-test fixtures are derived from the spec's documented examples, so tests need no network or credentials.
  • Verify coverage — implemented endpoints are diffed against the spec to guarantee full (184/184) coverage as the API evolves.

The submodule is development-only: it is excluded from the published crate. Clone it with:

git clone --recurse-submodules https://github.com/obazin/litchee
# or, in an existing clone:
git submodule update --init --recursive

Development

cargo build
cargo test                                       # unit + integration tests
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt
cargo doc --no-deps --open

Contributing

Contributions are welcome. Please keep the project conventions: one concern per module under the right src/api/ category, Lichess*-prefixed DTOs, an integration test per endpoint plus unit tests for pure functions, and a clean cargo fmt / cargo clippy -D warnings. The vendored spec under reference/ is the source of truth for endpoints and types.

License

Licensed under the MIT License.

About

Async, builder-pattern Rust client for the Lichess API: full endpoint coverage, NDJSON streaming, and OAuth2 PKCE ('Log in with Lichess').

Topics

Resources

License

Stars

Watchers

Forks

Contributors