use std::fmt::Display;
use futures_util::stream::BoxStream;
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, LAST_MODIFIED, RETRY_AFTER};
use reqwest::{RequestBuilder, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use tokio::time::sleep;
use crate::error::{ApiError, LichessError, Result};
use crate::retry::RetryPolicy;
use crate::stream::ndjson;
#[derive(Debug, Deserialize)]
struct ErrorBody {
error: String,
}
pub(crate) const ACCEPT_NDJSON: &str = "application/x-ndjson";
pub(crate) const ACCEPT_PGN: &str = "application/x-chess-pgn";
pub(crate) const ACCEPT_JSON: &str = "application/json";
pub(crate) const CONTENT_TYPE_FORM: &str = "application/x-www-form-urlencoded";
pub(crate) const CONTENT_TYPE_PLAIN: &str = "text/plain";
pub(crate) fn join_form(parts: &[String]) -> String {
parts
.iter()
.filter(|part| !part.is_empty())
.map(String::as_str)
.collect::<Vec<_>>()
.join("&")
}
const PATH_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~');
pub(crate) fn segment(value: &str) -> impl Display + '_ {
utf8_percent_encode(value, PATH_SEGMENT)
}
#[derive(Debug)]
pub(crate) struct ApiRequest {
builder: RequestBuilder,
retry: RetryPolicy,
}
impl ApiRequest {
pub(crate) fn new(builder: RequestBuilder, retry: RetryPolicy) -> Self {
Self { builder, retry }
}
pub(crate) fn header(mut self, name: HeaderName, value: &'static str) -> Self {
self.builder = self.builder.header(name, value);
self
}
pub(crate) fn query<T: Serialize + ?Sized>(mut self, query: &T) -> Self {
self.builder = self.builder.query(query);
self
}
pub(crate) fn form<T: Serialize + ?Sized>(mut self, form: &T) -> Self {
self.builder = self.builder.form(form);
self
}
pub(crate) fn form_parts<A, B>(self, core: &A, extra: &B) -> Self
where
A: Serialize + ?Sized,
B: Serialize + ?Sized,
{
let core = serde_urlencoded::to_string(core).unwrap_or_default();
let extra = serde_urlencoded::to_string(extra).unwrap_or_default();
self.form_body(join_form(&[core, extra]))
}
pub(crate) fn form_body(self, body: String) -> Self {
self.header(CONTENT_TYPE, CONTENT_TYPE_FORM).body(body)
}
pub(crate) fn json<T: Serialize + ?Sized>(mut self, json: &T) -> Self {
self.builder = self.builder.json(json);
self
}
pub(crate) fn body(mut self, body: impl Into<reqwest::Body>) -> Self {
self.builder = self.builder.body(body);
self
}
pub(crate) fn text_body(self, body: impl Into<reqwest::Body>) -> Self {
self.header(CONTENT_TYPE, CONTENT_TYPE_PLAIN).body(body)
}
pub(crate) async fn send(self) -> reqwest::Result<Response> {
let Self { mut builder, retry } = self;
let mut attempt = 0;
loop {
let next = (attempt < retry.max_retries())
.then(|| builder.try_clone())
.flatten();
let response = builder.send().await?;
let Some(retry_builder) = next.filter(|_| is_rate_limited(&response)) else {
return Ok(response);
};
sleep(retry.delay(attempt, response.headers())).await;
builder = retry_builder;
attempt += 1;
}
}
#[cfg(test)]
pub(crate) fn build(self) -> reqwest::Result<reqwest::Request> {
self.builder.build()
}
}
fn is_rate_limited(response: &Response) -> bool {
response.status() == StatusCode::TOO_MANY_REQUESTS
}
pub(crate) async fn send(request: ApiRequest) -> Result<Response> {
let response = request.send().await?;
if response.status().is_success() {
Ok(response)
} else {
Err(api_error(response).await)
}
}
async fn api_error(response: Response) -> LichessError {
let status = response.status();
let retry_after = retry_after_secs(response.headers());
let body = response.text().await.unwrap_or_default();
ApiError::new(status, error_message(&body), retry_after).into()
}
fn error_message(body: &str) -> Option<String> {
serde_json::from_str::<ErrorBody>(body)
.ok()
.map(|parsed| parsed.error)
}
fn retry_after_secs(headers: &HeaderMap) -> Option<u64> {
headers.get(RETRY_AFTER)?.to_str().ok()?.trim().parse().ok()
}
pub(crate) fn last_modified(headers: &HeaderMap) -> Option<String> {
Some(headers.get(LAST_MODIFIED)?.to_str().ok()?.to_owned())
}
pub(crate) async fn json<T: DeserializeOwned>(request: ApiRequest, context: &str) -> Result<T> {
let response = send(request).await?;
let bytes = response.bytes().await?;
serde_json::from_slice(&bytes).map_err(|source| LichessError::decode(context, source))
}
pub(crate) async fn text(request: ApiRequest) -> Result<String> {
let response = send(request).await?;
Ok(response.text().await?)
}
pub(crate) async fn ok(request: ApiRequest) -> Result<()> {
let response = send(request).await?;
drop(response.bytes().await?);
Ok(())
}
pub(crate) async fn stream<T: DeserializeOwned + Send + 'static>(
request: ApiRequest,
max_line_bytes: usize,
) -> Result<BoxStream<'static, Result<T>>> {
let response = send(request).await?;
Ok(Box::pin(ndjson(response, max_line_bytes)))
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::header::{HeaderValue, RETRY_AFTER};
#[test]
fn extracts_error_message_from_body() {
assert_eq!(
error_message(r#"{"error":"Not found."}"#),
Some("Not found.".to_owned())
);
}
#[test]
fn ignores_non_error_bodies() {
assert_eq!(error_message(r#"{"ok":true}"#), None);
assert_eq!(error_message("plain text"), None);
}
#[test]
fn join_form_drops_empty_parts() {
assert_eq!(join_form(&[]), "");
assert_eq!(join_form(&["a=1".to_owned(), String::new()]), "a=1");
assert_eq!(join_form(&[String::new(), "b=2".to_owned()]), "b=2");
assert_eq!(join_form(&["a=1".to_owned(), "b=2".to_owned()]), "a=1&b=2");
assert_eq!(join_form(&[String::new(), String::new()]), "");
}
#[test]
fn parses_retry_after_seconds() {
let mut headers = HeaderMap::new();
headers.insert(RETRY_AFTER, HeaderValue::from_static("60"));
assert_eq!(retry_after_secs(&headers), Some(60));
}
#[test]
fn missing_retry_after_is_none() {
assert_eq!(retry_after_secs(&HeaderMap::new()), None);
}
#[test]
fn reads_last_modified_header() {
let mut headers = HeaderMap::new();
headers.insert(
LAST_MODIFIED,
HeaderValue::from_static("Tue, 25 Apr 2023 13:23:09 GMT"),
);
assert_eq!(
last_modified(&headers).as_deref(),
Some("Tue, 25 Apr 2023 13:23:09 GMT")
);
assert_eq!(last_modified(&HeaderMap::new()), None);
}
#[test]
fn segment_encodes_path_breaking_characters() {
assert_eq!(segment("../a").to_string(), "..%2Fa");
assert_eq!(segment("a/b").to_string(), "a%2Fb");
assert_eq!(segment("a?b#c").to_string(), "a%3Fb%23c");
assert_eq!(segment("a b%c").to_string(), "a%20b%25c");
}
#[test]
fn segment_leaves_unreserved_characters_intact() {
assert_eq!(segment("normal-id_1.x~").to_string(), "normal-id_1.x~");
assert_eq!(segment("Lichess123").to_string(), "Lichess123");
}
}