use serde_json::Value;
use std::future::Future;
use std::time::Duration;
use super::models::Document;
use super::query::SearchQuery;
use crate::error::{Error, Result};
pub trait SearchClient: Send + Sync {
fn search(&self, query: &SearchQuery) -> impl Future<Output = Result<Vec<Document>>> + Send;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GameSystem {
#[default]
Pathfinder,
Starfinder,
}
impl GameSystem {
pub fn endpoint(&self) -> &'static str {
match self {
Self::Pathfinder => "https://elasticsearch.aonprd.com/aon/_search",
Self::Starfinder => "https://elasticsearch.aonprd.com/aonsf/_search",
}
}
pub fn index(&self) -> &'static str {
match self {
Self::Pathfinder => "aon70",
Self::Starfinder => "aonsf10",
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Pathfinder => "PF2e",
Self::Starfinder => "SF2e",
}
}
pub fn base_url(&self) -> &'static str {
match self {
Self::Pathfinder => "https://2e.aonprd.com",
Self::Starfinder => "https://2e.aonsrd.com",
}
}
}
#[derive(Clone)]
pub struct AonClient {
http: reqwest::Client,
pub system: GameSystem,
endpoint: String,
}
impl AonClient {
pub fn new(system: GameSystem) -> Result<Self> {
Self::with_endpoint(system, system.endpoint())
}
pub fn with_endpoint(system: GameSystem, endpoint: impl Into<String>) -> Result<Self> {
let _ = rustls::crypto::ring::default_provider().install_default();
let http = reqwest::Client::builder()
.user_agent(concat!(
"wayfinder-core/",
env!("CARGO_PKG_VERSION"),
" (+https://github.com/jhheider/wayfinder)"
))
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.build()?;
Ok(Self {
http,
system,
endpoint: endpoint.into(),
})
}
pub async fn search(&self, query: &SearchQuery) -> Result<Vec<Document>> {
let body = query.build();
let raw = self.search_raw(&body).await?;
parse_documents(&raw)
}
pub async fn search_raw(&self, body: &Value) -> Result<Value> {
let url = format!("{}?index={}", self.endpoint, self.system.index());
let mut attempt = 0u32;
loop {
let resp = self.do_post(&url, body).await?;
let status = resp.status();
let retriable = status == reqwest::StatusCode::TOO_MANY_REQUESTS
|| status == reqwest::StatusCode::SERVICE_UNAVAILABLE;
if retriable && attempt + 1 < MAX_ATTEMPTS {
let retry_after = resp
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok());
tokio::time::sleep(backoff_delay(retry_after, attempt)).await;
attempt += 1;
continue;
}
if !status.is_success() {
return Err(Error::HttpStatus {
status: status.as_u16(),
});
}
return Ok(resp.json().await?);
}
}
async fn do_post(&self, url: &str, body: &Value) -> Result<reqwest::Response> {
Ok(self.http.post(url).json(body).send().await?)
}
}
const MAX_ATTEMPTS: u32 = 3;
fn backoff_delay(retry_after: Option<&str>, attempt: u32) -> Duration {
const CAP: Duration = Duration::from_secs(30);
if let Some(secs) = retry_after.and_then(|v| v.trim().parse::<u64>().ok()) {
return Duration::from_secs(secs).min(CAP);
}
let ms = 500u64.saturating_mul(1u64 << attempt.min(6));
Duration::from_millis(ms).min(CAP)
}
impl SearchClient for AonClient {
async fn search(&self, query: &SearchQuery) -> Result<Vec<Document>> {
AonClient::search(self, query).await
}
}
pub fn parse_documents(response: &Value) -> Result<Vec<Document>> {
let hits = response
.pointer("/hits/hits")
.and_then(|v| v.as_array())
.ok_or_else(|| Error::UnexpectedResponse("missing hits.hits".to_string()))?;
hits.iter()
.filter_map(|hit| hit.get("_source"))
.map(|src| serde_json::from_value::<Document>(src.clone()).map_err(Error::from))
.collect()
}
pub fn parse_total(response: &Value) -> Option<i64> {
response
.pointer("/hits/total/value")
.and_then(serde_json::Value::as_i64)
}
#[cfg(test)]
mod tests {
use super::backoff_delay;
use std::time::Duration;
#[test]
fn retry_after_seconds_wins() {
assert_eq!(backoff_delay(Some("5"), 0), Duration::from_secs(5));
assert_eq!(backoff_delay(Some(" 3 "), 2), Duration::from_secs(3));
}
#[test]
fn retry_after_is_capped_at_30s() {
assert_eq!(backoff_delay(Some("120"), 0), Duration::from_secs(30));
}
#[test]
fn exponential_without_retry_after() {
assert_eq!(backoff_delay(None, 0), Duration::from_millis(500));
assert_eq!(backoff_delay(None, 1), Duration::from_millis(1000));
assert_eq!(backoff_delay(None, 2), Duration::from_millis(2000));
}
#[test]
fn exponential_is_capped_at_30s() {
assert_eq!(backoff_delay(None, 20), Duration::from_secs(30));
}
#[test]
fn invalid_retry_after_falls_back_to_exponential() {
assert_eq!(backoff_delay(Some("soon"), 0), Duration::from_millis(500));
}
}