Skip to main content

rfc/api/
rfc_editor.rs

1use anyhow::{Context, Result};
2use reqwest::Client;
3use serde::Deserialize;
4
5use crate::models::{DocumentType, Format};
6
7#[derive(Debug, Deserialize)]
8struct DraftInfo {
9    rev: Option<String>,
10}
11
12/// Fetches RFC and draft document content (HTML or plain text).
13///
14/// Talks primarily to rfc-editor.org and ietf.org/archive, with a side
15/// trip to datatracker.ietf.org to resolve `-NN` version suffixes for
16/// drafts the user supplied unversioned.
17pub struct DocumentFetcher {
18    client: Client,
19}
20
21impl DocumentFetcher {
22    /// Build a fetcher with a freshly-constructed HTTP client.
23    pub fn new() -> Result<Self> {
24        Ok(Self::with_client(super::build_http_client()?))
25    }
26
27    /// Build a fetcher that reuses an existing HTTP client. Lets a single
28    /// client back both this and `DataTrackerClient` so we don't pay for
29    /// two connection pools per command invocation.
30    pub fn with_client(client: Client) -> Self {
31        Self { client }
32    }
33
34    /// Fetch a document, preferring plain text and falling back to HTML.
35    ///
36    /// Drafts without a version suffix are resolved to their latest
37    /// revision via datatracker before fetching.
38    pub async fn fetch(&self, doc: &DocumentType) -> Result<(String, Format)> {
39        let doc = self.resolve_draft_version(doc).await?;
40
41        let text_url = self.text_url(&doc);
42        match self.fetch_content(&text_url).await {
43            Ok(content) => Ok((content, Format::Text)),
44            Err(text_err) => {
45                let html_url = self.html_url(&doc);
46                let content = self.fetch_content(&html_url).await.with_context(|| {
47                    format!(
48                        "Plain text fetch failed ({}); HTML fallback also failed",
49                        text_err
50                    )
51                })?;
52                Ok((content, Format::Html))
53            }
54        }
55    }
56
57    /// Resolve a draft name to include its latest version suffix.
58    /// RFCs and already-versioned drafts pass through unchanged.
59    async fn resolve_draft_version(&self, doc: &DocumentType) -> Result<DocumentType> {
60        match doc {
61            DocumentType::Rfc(_) => Ok(doc.clone()),
62            DocumentType::Draft(name) => {
63                if Self::has_version_suffix(name) {
64                    return Ok(doc.clone());
65                }
66
67                let url = format!("https://datatracker.ietf.org/doc/{}/doc.json", name);
68                let response = self
69                    .client
70                    .get(&url)
71                    .send()
72                    .await
73                    .context("Failed to query draft info")?;
74
75                if !response.status().is_success() {
76                    anyhow::bail!("Draft not found: {}", name);
77                }
78
79                let info: DraftInfo = response
80                    .json()
81                    .await
82                    .context("Failed to parse draft info")?;
83
84                match info.rev {
85                    Some(rev) => Ok(DocumentType::Draft(format!("{}-{}", name, rev))),
86                    None => Ok(doc.clone()),
87                }
88            }
89        }
90    }
91
92    /// True when `name` ends in `-` followed by ASCII digits (e.g. `-06`,
93    /// `-123456`). Used to detect whether a draft name is already pinned
94    /// to a specific revision.
95    fn has_version_suffix(name: &str) -> bool {
96        if let Some(last_dash) = name.rfind('-') {
97            let suffix = &name[last_dash + 1..];
98            !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit())
99        } else {
100            false
101        }
102    }
103
104    /// HTML URL for a document.
105    pub fn html_url(&self, doc: &DocumentType) -> String {
106        match doc {
107            DocumentType::Rfc(num) => {
108                format!("https://www.rfc-editor.org/rfc/rfc{}.html", num)
109            }
110            DocumentType::Draft(name) => {
111                format!("https://datatracker.ietf.org/doc/html/{}", name)
112            }
113        }
114    }
115
116    /// Plain-text URL for a document.
117    pub fn text_url(&self, doc: &DocumentType) -> String {
118        match doc {
119            DocumentType::Rfc(num) => {
120                format!("https://www.rfc-editor.org/rfc/rfc{}.txt", num)
121            }
122            DocumentType::Draft(name) => {
123                format!("https://www.ietf.org/archive/id/{}.txt", name)
124            }
125        }
126    }
127
128    async fn fetch_content(&self, url: &str) -> Result<String> {
129        let response = self
130            .client
131            .get(url)
132            .send()
133            .await
134            .context("Failed to fetch document")?;
135
136        if !response.status().is_success() {
137            anyhow::bail!("Failed to fetch {}: HTTP {}", url, response.status());
138        }
139
140        response
141            .text()
142            .await
143            .context("Failed to read document content")
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_rfc_urls() {
153        let editor = DocumentFetcher::new().unwrap();
154
155        assert_eq!(
156            editor.html_url(&DocumentType::Rfc(9000)),
157            "https://www.rfc-editor.org/rfc/rfc9000.html"
158        );
159        assert_eq!(
160            editor.text_url(&DocumentType::Rfc(9000)),
161            "https://www.rfc-editor.org/rfc/rfc9000.txt"
162        );
163    }
164
165    #[test]
166    fn test_draft_urls() {
167        let editor = DocumentFetcher::new().unwrap();
168        let draft = DocumentType::Draft("draft-ietf-quic-transport-34".to_string());
169
170        assert_eq!(
171            editor.html_url(&draft),
172            "https://datatracker.ietf.org/doc/html/draft-ietf-quic-transport-34"
173        );
174        assert_eq!(
175            editor.text_url(&draft),
176            "https://www.ietf.org/archive/id/draft-ietf-quic-transport-34.txt"
177        );
178    }
179
180    #[test]
181    fn test_has_version_suffix() {
182        // Has version suffix
183        assert!(DocumentFetcher::has_version_suffix(
184            "draft-ietf-quic-transport-34"
185        ));
186        assert!(DocumentFetcher::has_version_suffix("draft-foo-00"));
187        assert!(DocumentFetcher::has_version_suffix("draft-test-123456"));
188
189        // No version suffix
190        assert!(!DocumentFetcher::has_version_suffix(
191            "draft-ietf-quic-transport"
192        ));
193        assert!(!DocumentFetcher::has_version_suffix("draft-foo-bar-v2")); // v2 has letter
194        assert!(!DocumentFetcher::has_version_suffix("draft-foo-bar-")); // empty suffix
195        assert!(!DocumentFetcher::has_version_suffix("draftname")); // no dash
196        assert!(!DocumentFetcher::has_version_suffix("")); // empty string
197    }
198}