Skip to main content

rfc/api/
datatracker.rs

1use anyhow::{Context, Result};
2use reqwest::Client;
3use serde::Deserialize;
4
5use crate::models::{Document, DocumentType, SearchFilter, SearchResult};
6
7pub const DATATRACKER_BASE_URL: &str = "https://datatracker.ietf.org";
8
9/// Client for the IETF Datatracker REST API. Used for search and for
10/// metadata lookups (titles, draft revisions).
11pub struct DataTrackerClient {
12    client: Client,
13}
14
15#[derive(Debug, Deserialize)]
16struct SearchResponse {
17    meta: SearchMeta,
18    objects: Vec<ApiDocument>,
19}
20
21#[derive(Debug, Deserialize)]
22struct SearchMeta {
23    #[serde(default)]
24    total_count: Option<u32>,
25    #[serde(default)]
26    next: Option<String>,
27}
28
29/// Document as returned by the Datatracker API.
30///
31/// Only the fields the CLI consumes are deserialized; the API returns a
32/// great deal more (pages, authors, timestamps, etc.) that we ignore.
33/// `abstract_text` is read by the multi-token local filter in `search`,
34/// not stored on `Document`.
35#[derive(Debug, Deserialize)]
36struct ApiDocument {
37    name: String,
38    title: String,
39    #[serde(rename = "abstract")]
40    abstract_text: Option<String>,
41}
42
43impl DataTrackerClient {
44    /// Build a client with a freshly-constructed HTTP client.
45    pub fn new() -> Result<Self> {
46        Ok(Self::with_client(super::build_http_client()?))
47    }
48
49    /// Build a client that reuses an existing HTTP client.
50    pub fn with_client(client: Client) -> Self {
51        Self { client }
52    }
53
54    /// Search for documents matching the query.
55    ///
56    /// The query is tokenized on whitespace and pushed to the server as
57    /// much as possible:
58    ///
59    /// - the longest token becomes a `title__icontains` filter,
60    /// - the second-longest (if any) becomes an `abstract__icontains` filter,
61    /// - `type__in` honors the caller's `SearchFilter`, defaulting to
62    ///   `rfc,draft` so the response doesn't include slides, charters, etc.
63    ///
64    /// Any remaining (3rd+) tokens are AND-ed locally against
65    /// title+abstract. This makes queries like "bgp message" work without
66    /// the user having to guess the exact phrase, while keeping the JSON
67    /// payload (and latency) small.
68    pub async fn search(
69        &self,
70        query: &str,
71        filter: SearchFilter,
72        limit: u32,
73    ) -> Result<SearchResult> {
74        let tokens: Vec<String> = query.split_whitespace().map(|t| t.to_lowercase()).collect();
75
76        // Pick the longest token for the title filter, the second-longest for
77        // the abstract filter. Falls back to the raw query when there are no
78        // whitespace-separated tokens (e.g. empty input).
79        let mut by_length: Vec<&str> = tokens.iter().map(String::as_str).collect();
80        by_length.sort_by_key(|t| std::cmp::Reverse(t.len()));
81        let primary_token = by_length.first().copied().unwrap_or(query);
82        let secondary_token = by_length.get(1).copied();
83
84        // Server-side type filter. If the user asked for --rfc or --draft we
85        // honor that; otherwise we restrict to rfc+draft so the response
86        // doesn't waste rows on slides, charters, reviews, etc.
87        let type_filter = filter.api_param().unwrap_or("rfc,draft");
88
89        // Cushion sizing. With both title and abstract filters server-side,
90        // multi-token queries are already very selective — asking for the
91        // user's limit verbatim is enough. Single-token queries lack the
92        // abstract filter, so we keep a small cushion (3x) for the
93        // ID-ordering-fallthrough effect we observed in benchmarks.
94        let base_limit = limit.max(25);
95        let api_limit = if secondary_token.is_some() {
96            base_limit
97        } else {
98            base_limit.saturating_mul(3)
99        };
100
101        let mut url = format!(
102            "{}/api/v1/doc/document/?title__icontains={}&type__in={}&limit={}&format=json",
103            DATATRACKER_BASE_URL,
104            urlencoding::encode(primary_token),
105            type_filter,
106            api_limit
107        );
108        if let Some(s) = secondary_token {
109            url.push_str(&format!("&abstract__icontains={}", urlencoding::encode(s)));
110        }
111
112        let response = self
113            .client
114            .get(&url)
115            .send()
116            .await
117            .context("Failed to send search request")?;
118
119        if !response.status().is_success() {
120            anyhow::bail!(
121                "Search request to {} failed: HTTP {}",
122                url,
123                response.status()
124            );
125        }
126
127        let search_response: SearchResponse = response
128            .json()
129            .await
130            .context("Failed to parse search response")?;
131
132        // 3rd+ tokens weren't sent to the API; each must still match locally
133        // against title or abstract for the document to be included.
134        let extra_tokens: Vec<&str> = by_length.iter().copied().skip(2).collect();
135
136        let matches_extra_tokens = |doc: &ApiDocument| -> bool {
137            if extra_tokens.is_empty() {
138                return true;
139            }
140            let title_lc = doc.title.to_lowercase();
141            let abstract_lc = doc.abstract_text.as_deref().unwrap_or("").to_lowercase();
142            extra_tokens
143                .iter()
144                .all(|tok| title_lc.contains(tok) || abstract_lc.contains(tok))
145        };
146
147        // Filter to only RFCs and drafts that match all query tokens, then take
148        // up to the requested limit.
149        let documents: Vec<Document> = search_response
150            .objects
151            .into_iter()
152            .filter(|doc| Self::is_rfc_or_draft(&doc.name))
153            .filter(matches_extra_tokens)
154            .map(Document::from)
155            .take(limit as usize)
156            .collect();
157
158        // The API's total_count reflects all server-side filters (title,
159        // abstract, type) — it's accurate when we have no further local
160        // filtering to do. With 3+ tokens we filter locally too, so drop it.
161        let total_count = if extra_tokens.is_empty() {
162            search_response.meta.total_count
163        } else {
164            None
165        };
166
167        Ok(SearchResult {
168            documents,
169            has_more: search_response.meta.next.is_some(),
170            total_count,
171            query: query.to_string(),
172            filter,
173        })
174    }
175
176    fn is_rfc_or_draft(name: &str) -> bool {
177        name.starts_with("rfc") || name.starts_with("draft-")
178    }
179
180    /// Fetch a single document's metadata by canonical name.
181    pub async fn get_document(&self, name: &str) -> Result<Document> {
182        let url = format!(
183            "{}/api/v1/doc/document/{}/?format=json",
184            DATATRACKER_BASE_URL, name
185        );
186
187        let response = self
188            .client
189            .get(&url)
190            .send()
191            .await
192            .context("Failed to fetch document metadata")?;
193
194        if !response.status().is_success() {
195            anyhow::bail!("Document not found: {}", name);
196        }
197
198        let api_doc: ApiDocument = response
199            .json()
200            .await
201            .context("Failed to parse document metadata")?;
202
203        Ok(api_doc.into())
204    }
205}
206
207impl From<ApiDocument> for Document {
208    fn from(doc: ApiDocument) -> Self {
209        let doc_type = DocumentType::from_canonical_name(&doc.name);
210        Document {
211            name: doc.name,
212            title: doc.title,
213            doc_type,
214        }
215    }
216}