Skip to main content

gws_builder/
fetch.rs

1//! Blocking HTTP fetch for Discovery documents and directory resolution.
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6
7use serde::Deserialize;
8use ureq::Agent;
9
10use crate::error::BuilderError;
11
12/// Fetches raw Discovery JSON for a service/version pair.
13pub trait DiscoveryFetcher: Send + Sync {
14    /// Returns the raw JSON body of the Discovery REST document.
15    fn fetch_document(&self, service: &str, version: &str) -> Result<String, BuilderError>;
16}
17
18/// Validates service and version strings (alphanumeric, dot, underscore, hyphen).
19pub fn validate_api_identifier(s: &str) -> Result<(), BuilderError> {
20    if s.is_empty() {
21        return Err(BuilderError::Resolution(
22            "API identifier must not be empty".into(),
23        ));
24    }
25    if !s
26        .chars()
27        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
28    {
29        return Err(BuilderError::Resolution(format!(
30            "invalid API identifier {s:?}: only [a-zA-Z0-9._-] allowed"
31        )));
32    }
33    Ok(())
34}
35
36#[derive(Debug, Deserialize)]
37#[serde(rename_all = "camelCase")]
38struct ApiDirectoryItem {
39    name: String,
40    version: String,
41    #[serde(default)]
42    discovery_rest_url: Option<String>,
43}
44
45#[derive(Debug, Deserialize)]
46struct ApiDirectoryList {
47    #[serde(default)]
48    items: Vec<ApiDirectoryItem>,
49}
50
51fn encode_path_segment(s: &str) -> String {
52    // Discovery URLs use plain segments; validate_api_identifier already restricts chars.
53    s.to_string()
54}
55
56/// Default blocking HTTP implementation using `ureq`.
57pub struct HttpFetcher {
58    agent: Agent,
59}
60
61impl Default for HttpFetcher {
62    fn default() -> Self {
63        Self {
64            agent: Agent::new_with_config(
65                ureq::config::Config::builder()
66                    .timeout_global(Some(std::time::Duration::from_secs(120)))
67                    .build(),
68            ),
69        }
70    }
71}
72
73impl HttpFetcher {
74    /// Fetch with optional on-disk cache: on success, writes JSON to
75    /// `cache_dir/{service}_{version}.json`; on network failure, reads cache if present.
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    fn fetch_directory(&self) -> Result<ApiDirectoryList, BuilderError> {
81        let url = "https://www.googleapis.com/discovery/v1/apis";
82        let body = self
83            .agent
84            .get(url)
85            .call()
86            .map_err(|e| BuilderError::Fetch {
87                service: "directory".into(),
88                version: "v1".into(),
89                source: Box::new(e),
90            })?
91            .into_body()
92            .read_to_string()
93            .map_err(|e| BuilderError::Fetch {
94                service: "directory".into(),
95                version: "v1".into(),
96                source: Box::new(e),
97            })?;
98        serde_json::from_str(&body).map_err(|e| BuilderError::Parse {
99            service: "directory".into(),
100            source: e,
101        })
102    }
103
104    fn find_discovery_url(
105        &self,
106        service: &str,
107        version: &str,
108    ) -> Result<Option<String>, BuilderError> {
109        let list = self.fetch_directory()?;
110        Ok(list
111            .items
112            .into_iter()
113            .find(|i| i.name == service && i.version == version)
114            .and_then(|i| i.discovery_rest_url))
115    }
116
117    fn fetch_url(&self, url: &str, service: &str, version: &str) -> Result<String, BuilderError> {
118        self.agent
119            .get(url)
120            .call()
121            .map_err(|e| BuilderError::Fetch {
122                service: service.into(),
123                version: version.into(),
124                source: Box::new(e),
125            })?
126            .into_body()
127            .read_to_string()
128            .map_err(|e| BuilderError::Fetch {
129                service: service.into(),
130                version: version.into(),
131                source: Box::new(e),
132            })
133    }
134}
135
136impl DiscoveryFetcher for HttpFetcher {
137    fn fetch_document(&self, service: &str, version: &str) -> Result<String, BuilderError> {
138        validate_api_identifier(service)?;
139        validate_api_identifier(version)?;
140
141        let primary = format!(
142            "https://www.googleapis.com/discovery/v1/apis/{}/{}/rest",
143            encode_path_segment(service),
144            encode_path_segment(version)
145        );
146
147        let resp = self.agent.get(&primary).call();
148        let body = match resp {
149            Ok(r) => {
150                let status = r.status().as_u16();
151                if (200..300).contains(&status) {
152                    r.into_body()
153                        .read_to_string()
154                        .map_err(|e| BuilderError::Fetch {
155                            service: service.into(),
156                            version: version.into(),
157                            source: Box::new(e),
158                        })?
159                } else {
160                    // Try directory canonical URL
161                    if let Ok(Some(url)) = self.find_discovery_url(service, version) {
162                        self.fetch_url(&url, service, version)?
163                    } else {
164                        let alt = format!(
165                            "https://{service}.googleapis.com/$discovery/rest?version={version}"
166                        );
167                        self.fetch_url(&alt, service, version)?
168                    }
169                }
170            }
171            Err(_) => {
172                if let Ok(Some(url)) = self.find_discovery_url(service, version) {
173                    self.fetch_url(&url, service, version)?
174                } else {
175                    let alt = format!(
176                        "https://{service}.googleapis.com/$discovery/rest?version={version}"
177                    );
178                    self.fetch_url(&alt, service, version)?
179                }
180            }
181        };
182
183        Ok(body)
184    }
185}
186
187/// Fetcher that reads from a map of `(service, version) -> JSON` for tests.
188pub struct MapFetcher {
189    pub docs: HashMap<(String, String), String>,
190}
191
192impl DiscoveryFetcher for MapFetcher {
193    fn fetch_document(&self, service: &str, version: &str) -> Result<String, BuilderError> {
194        self.docs
195            .get(&(service.to_string(), version.to_string()))
196            .cloned()
197            .ok_or_else(|| {
198                BuilderError::Resolution(format!(
199                    "MapFetcher: no document for {service}/{version}"
200                ))
201            })
202    }
203}
204
205/// Reads a cached Discovery JSON file if it exists.
206pub fn read_cache(cache_dir: &Path, service: &str, version: &str) -> Option<String> {
207    let path = cache_dir.join(format!("{service}_{version}.json"));
208    fs::read_to_string(path).ok()
209}
210
211/// Writes successful fetch to cache directory (best-effort).
212pub fn write_cache(cache_dir: &Path, service: &str, version: &str, json: &str) {
213    if let Err(e) = fs::create_dir_all(cache_dir) {
214        eprintln!("gws-builder: could not create cache dir {}: {e}", cache_dir.display());
215        return;
216    }
217    let path = cache_dir.join(format!("{service}_{version}.json"));
218    if let Err(e) = fs::write(&path, json) {
219        eprintln!("gws-builder: could not write cache {}: {e}", path.display());
220    }
221}