use futures_util::stream::BoxStream;
use reqwest::{Method, StatusCode};
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::secret::Secret;
#[derive(Debug)]
pub struct ExternalEngineApi<'a> {
client: &'a LichessClient,
}
impl<'a> ExternalEngineApi<'a> {
pub(crate) fn new(client: &'a LichessClient) -> Self {
Self { client }
}
pub async fn list(&self) -> Result<Vec<LichessExternalEngine>> {
let request = self
.client
.request(Method::GET, Host::Default, "/api/external-engine");
http::json(request, "Vec<LichessExternalEngine>").await
}
pub async fn get(&self, id: &str) -> Result<LichessExternalEngine> {
let path = format!("/api/external-engine/{}", http::segment(id));
let request = self.client.request(Method::GET, Host::Default, &path);
http::json(request, "LichessExternalEngine").await
}
pub async fn create(
&self,
registration: &LichessExternalEngineRegistration,
) -> Result<LichessExternalEngine> {
let request = self
.client
.request(Method::POST, Host::Default, "/api/external-engine")
.json(registration);
http::json(request, "LichessExternalEngine").await
}
pub async fn update(
&self,
id: &str,
registration: &LichessExternalEngineRegistration,
) -> Result<LichessExternalEngine> {
let path = format!("/api/external-engine/{}", http::segment(id));
let request = self
.client
.request(Method::PUT, Host::Default, &path)
.json(registration);
http::json(request, "LichessExternalEngine").await
}
pub async fn delete(&self, id: &str) -> Result<()> {
let path = format!("/api/external-engine/{}", http::segment(id));
http::ok(self.client.request(Method::DELETE, Host::Default, &path)).await
}
pub async fn analyse(
&self,
id: &str,
client_secret: &str,
work: &LichessExternalEngineWork,
) -> Result<BoxStream<'static, Result<Value>>> {
let path = format!("/api/external-engine/{}/analyse", http::segment(id));
let body = serde_json::json!({ "clientSecret": client_secret, "work": work });
let request = self
.client
.request(Method::POST, Host::Engine, &path)
.json(&body);
http::stream(request, self.client.max_line_bytes()).await
}
pub async fn acquire_work(&self, provider_secret: &str) -> Result<Option<LichessEngineWork>> {
let body = serde_json::json!({ "providerSecret": provider_secret });
let request = self
.client
.request(Method::POST, Host::Engine, "/api/external-engine/work")
.json(&body);
let response = http::send(request).await?;
if response.status() == StatusCode::NO_CONTENT {
return Ok(None);
}
let bytes = response.bytes().await?;
serde_json::from_slice(&bytes)
.map(Some)
.map_err(|err| crate::error::LichessError::decode("LichessEngineWork", err))
}
pub async fn submit_work(&self, work_id: &str, output: &str) -> Result<()> {
let path = format!("/api/external-engine/work/{}", http::segment(work_id));
let request = self
.client
.request(Method::POST, Host::Engine, &path)
.text_body(output.to_owned());
http::ok(request).await
}
}
impl LichessClient {
#[must_use]
pub fn external_engine(&self) -> ExternalEngineApi<'_> {
ExternalEngineApi::new(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessExternalEngine {
pub id: String,
pub name: String,
pub client_secret: Secret<String>,
pub user_id: String,
pub max_threads: u32,
pub max_hash: u32,
#[serde(default)]
pub variants: Vec<String>,
#[serde(default)]
pub provider_data: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LichessExternalEngineRegistration {
pub name: String,
pub max_threads: u32,
pub max_hash: u32,
pub provider_secret: Secret<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub variants: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_data: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_external_engine() {
let json = r#"{"id":"eng","name":"My Engine","clientSecret":"s","userId":"u",
"maxThreads":8,"maxHash":256,"variants":["chess"]}"#;
let engine: LichessExternalEngine = serde_json::from_str(json).unwrap();
assert_eq!(engine.id, "eng");
assert_eq!(engine.max_threads, 8);
assert_eq!(engine.variants, vec!["chess"]);
}
#[test]
fn engine_debug_redacts_client_secret() {
let engine = LichessExternalEngine {
id: "eng".to_owned(),
name: "My Engine".to_owned(),
client_secret: Secret::new("supersecret".to_owned()),
user_id: "u".to_owned(),
max_threads: 8,
max_hash: 256,
variants: vec!["chess".to_owned()],
provider_data: None,
};
let debug = format!("{engine:?}");
assert!(!debug.contains("supersecret"));
assert!(debug.contains("<redacted>"));
assert!(debug.contains("My Engine"));
}
#[test]
fn registration_debug_redacts_provider_secret() {
let registration = LichessExternalEngineRegistration {
name: "E".to_owned(),
max_threads: 4,
max_hash: 128,
provider_secret: Secret::new("supersecret".to_owned()),
..Default::default()
};
let debug = format!("{registration:?}");
assert!(!debug.contains("supersecret"));
assert!(debug.contains("<redacted>"));
}
#[test]
fn registration_skips_empty_optional_fields() {
let registration = LichessExternalEngineRegistration {
name: "E".to_owned(),
max_threads: 4,
max_hash: 128,
provider_secret: Secret::new("secret".to_owned()),
..Default::default()
};
let json = serde_json::to_string(®istration).unwrap();
assert!(!json.contains("variants"));
assert!(!json.contains("providerData"));
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LichessExternalEngineWork {
pub session_id: String,
pub threads: u32,
pub hash: u32,
pub multi_pv: u32,
pub variant: String,
pub initial_fen: String,
#[serde(default)]
pub moves: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub movetime: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub depth: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nodes: Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct LichessEngineWork {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(flatten)]
pub other: std::collections::HashMap<String, serde_json::Value>,
}