use anyhow::{Context, Result};
use reqwest::Client;
use serde::Deserialize;
use crate::models::{Document, DocumentType, SearchFilter, SearchResult};
pub const DATATRACKER_BASE_URL: &str = "https://datatracker.ietf.org";
pub struct DataTrackerClient {
client: Client,
}
#[derive(Debug, Deserialize)]
struct SearchResponse {
meta: SearchMeta,
objects: Vec<ApiDocument>,
}
#[derive(Debug, Deserialize)]
struct SearchMeta {
#[serde(default)]
total_count: Option<u32>,
#[serde(default)]
next: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ApiDocument {
name: String,
title: String,
#[serde(rename = "abstract")]
abstract_text: Option<String>,
}
impl DataTrackerClient {
pub fn new() -> Result<Self> {
Ok(Self::with_client(super::build_http_client()?))
}
pub fn with_client(client: Client) -> Self {
Self { client }
}
pub async fn search(
&self,
query: &str,
filter: SearchFilter,
limit: u32,
) -> Result<SearchResult> {
let tokens: Vec<String> = query.split_whitespace().map(|t| t.to_lowercase()).collect();
let mut by_length: Vec<&str> = tokens.iter().map(String::as_str).collect();
by_length.sort_by_key(|t| std::cmp::Reverse(t.len()));
let primary_token = by_length.first().copied().unwrap_or(query);
let secondary_token = by_length.get(1).copied();
let type_filter = filter.api_param().unwrap_or("rfc,draft");
let base_limit = limit.max(25);
let api_limit = if secondary_token.is_some() {
base_limit
} else {
base_limit.saturating_mul(3)
};
let mut url = format!(
"{}/api/v1/doc/document/?title__icontains={}&type__in={}&limit={}&format=json",
DATATRACKER_BASE_URL,
urlencoding::encode(primary_token),
type_filter,
api_limit
);
if let Some(s) = secondary_token {
url.push_str(&format!("&abstract__icontains={}", urlencoding::encode(s)));
}
let response = self
.client
.get(&url)
.send()
.await
.context("Failed to send search request")?;
if !response.status().is_success() {
anyhow::bail!(
"Search request to {} failed: HTTP {}",
url,
response.status()
);
}
let search_response: SearchResponse = response
.json()
.await
.context("Failed to parse search response")?;
let extra_tokens: Vec<&str> = by_length.iter().copied().skip(2).collect();
let matches_extra_tokens = |doc: &ApiDocument| -> bool {
if extra_tokens.is_empty() {
return true;
}
let title_lc = doc.title.to_lowercase();
let abstract_lc = doc.abstract_text.as_deref().unwrap_or("").to_lowercase();
extra_tokens
.iter()
.all(|tok| title_lc.contains(tok) || abstract_lc.contains(tok))
};
let documents: Vec<Document> = search_response
.objects
.into_iter()
.filter(|doc| Self::is_rfc_or_draft(&doc.name))
.filter(matches_extra_tokens)
.map(Document::from)
.take(limit as usize)
.collect();
let total_count = if extra_tokens.is_empty() {
search_response.meta.total_count
} else {
None
};
Ok(SearchResult {
documents,
has_more: search_response.meta.next.is_some(),
total_count,
query: query.to_string(),
filter,
})
}
fn is_rfc_or_draft(name: &str) -> bool {
name.starts_with("rfc") || name.starts_with("draft-")
}
pub async fn get_document(&self, name: &str) -> Result<Document> {
let url = format!(
"{}/api/v1/doc/document/{}/?format=json",
DATATRACKER_BASE_URL, name
);
let response = self
.client
.get(&url)
.send()
.await
.context("Failed to fetch document metadata")?;
if !response.status().is_success() {
anyhow::bail!("Document not found: {}", name);
}
let api_doc: ApiDocument = response
.json()
.await
.context("Failed to parse document metadata")?;
Ok(api_doc.into())
}
}
impl From<ApiDocument> for Document {
fn from(doc: ApiDocument) -> Self {
let doc_type = DocumentType::from_canonical_name(&doc.name);
Document {
name: doc.name,
title: doc.title,
doc_type,
}
}
}