1use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6
7use serde::Deserialize;
8use ureq::Agent;
9
10use crate::error::BuilderError;
11
12pub trait DiscoveryFetcher: Send + Sync {
14 fn fetch_document(&self, service: &str, version: &str) -> Result<String, BuilderError>;
16}
17
18pub 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 s.to_string()
54}
55
56pub 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 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 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
187pub 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
205pub 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
211pub 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}