husako_openapi/
release.rs1use std::collections::HashMap;
2use std::path::Path;
3
4use serde_json::Value;
5
6use crate::OpenApiError;
7
8pub fn fetch_release_specs(
13 version: &str,
14 cache_dir: &Path,
15) -> Result<HashMap<String, Value>, OpenApiError> {
16 let tag = version_to_tag(version);
17 let tag_cache = cache_dir.join(format!("release/{tag}"));
18
19 if tag_cache.exists() {
21 return load_cached_specs(&tag_cache);
22 }
23
24 let client = build_http_client()?;
26 let contents_url = format!(
27 "https://api.github.com/repos/kubernetes/kubernetes/contents/api/openapi-spec/v3?ref={tag}"
28 );
29
30 let resp = client
31 .get(&contents_url)
32 .header("User-Agent", "husako")
33 .header("Accept", "application/vnd.github.v3+json")
34 .send()
35 .map_err(|e| release_err(format!("GitHub API request failed: {e}")))?;
36
37 if !resp.status().is_success() {
38 return Err(release_err(format!(
39 "GitHub API returned {} for tag '{tag}'",
40 resp.status()
41 )));
42 }
43
44 let entries: Vec<GithubContent> = resp
45 .json()
46 .map_err(|e| release_err(format!("parse GitHub response: {e}")))?;
47
48 let spec_files: Vec<&GithubContent> = entries
50 .iter()
51 .filter(|e| is_openapi_spec_file(&e.name))
52 .collect();
53
54 if spec_files.is_empty() {
55 return Err(release_err(format!(
56 "no OpenAPI spec files found for tag '{tag}'"
57 )));
58 }
59
60 let mut specs = HashMap::new();
62 std::fs::create_dir_all(&tag_cache).map_err(|e| {
63 OpenApiError::Cache(format!("create cache dir {}: {e}", tag_cache.display()))
64 })?;
65
66 for entry in &spec_files {
67 let download_url = entry
68 .download_url
69 .as_deref()
70 .ok_or_else(|| release_err(format!("no download_url for {}", entry.name)))?;
71
72 let spec_resp = client
73 .get(download_url)
74 .header("User-Agent", "husako")
75 .send()
76 .map_err(|e| release_err(format!("download {}: {e}", entry.name)))?;
77
78 if !spec_resp.status().is_success() {
79 return Err(release_err(format!(
80 "download {} returned {}",
81 entry.name,
82 spec_resp.status()
83 )));
84 }
85
86 let spec: Value = spec_resp
87 .json()
88 .map_err(|e| release_err(format!("parse {}: {e}", entry.name)))?;
89
90 let discovery_key = filename_to_discovery_key(&entry.name);
91
92 let cache_file = tag_cache.join(&entry.name);
94 let _ = std::fs::write(
95 &cache_file,
96 serde_json::to_string(&spec).unwrap_or_default(),
97 );
98
99 specs.insert(discovery_key, spec);
100 }
101
102 let manifest: Vec<(String, String)> = spec_files
104 .iter()
105 .map(|e| (filename_to_discovery_key(&e.name), e.name.clone()))
106 .collect();
107 let manifest_json = serde_json::to_string(&manifest).unwrap_or_default();
108 let _ = std::fs::write(tag_cache.join("_manifest.json"), manifest_json);
109
110 Ok(specs)
111}
112
113pub fn version_to_tag(version: &str) -> String {
116 let v = version.strip_prefix('v').unwrap_or(version);
117 let parts: Vec<&str> = v.split('.').collect();
118 match parts.len() {
119 2 => format!("v{}.0", v),
120 3 => format!("v{v}"),
121 _ => format!("v{v}"),
122 }
123}
124
125pub fn filename_to_discovery_key(filename: &str) -> String {
128 filename
129 .trim_end_matches("_openapi.json")
130 .replace("__", "/")
131}
132
133fn is_openapi_spec_file(name: &str) -> bool {
135 name.ends_with("_openapi.json") && name != "api_openapi.json"
136}
137
138fn load_cached_specs(tag_cache: &Path) -> Result<HashMap<String, Value>, OpenApiError> {
139 let manifest_path = tag_cache.join("_manifest.json");
140 if manifest_path.exists() {
141 let manifest_data = std::fs::read_to_string(&manifest_path)
142 .map_err(|e| OpenApiError::Cache(format!("read manifest: {e}")))?;
143 let manifest: Vec<(String, String)> = serde_json::from_str(&manifest_data)
144 .map_err(|e| OpenApiError::Cache(format!("parse manifest: {e}")))?;
145
146 let mut specs = HashMap::new();
147 for (key, filename) in manifest {
148 let path = tag_cache.join(&filename);
149 let data = std::fs::read_to_string(&path)
150 .map_err(|e| OpenApiError::Cache(format!("read {}: {e}", path.display())))?;
151 let spec: Value = serde_json::from_str(&data)
152 .map_err(|e| OpenApiError::Cache(format!("parse {}: {e}", path.display())))?;
153 specs.insert(key, spec);
154 }
155 return Ok(specs);
156 }
157
158 let mut specs = HashMap::new();
160 let entries = std::fs::read_dir(tag_cache)
161 .map_err(|e| OpenApiError::Cache(format!("read cache dir: {e}")))?;
162 for entry in entries {
163 let entry = entry.map_err(|e| OpenApiError::Cache(format!("read entry: {e}")))?;
164 let path = entry.path();
165 if path.extension().is_some_and(|ext| ext == "json")
166 && path.file_name().is_some_and(|n| n != "_manifest.json")
167 {
168 let filename = path.file_name().unwrap().to_string_lossy();
169 if is_openapi_spec_file(&filename) {
170 let data = std::fs::read_to_string(&path)
171 .map_err(|e| OpenApiError::Cache(format!("read {}: {e}", path.display())))?;
172 let spec: Value = serde_json::from_str(&data)
173 .map_err(|e| OpenApiError::Cache(format!("parse {}: {e}", path.display())))?;
174 specs.insert(filename_to_discovery_key(&filename), spec);
175 }
176 }
177 }
178 Ok(specs)
179}
180
181fn build_http_client() -> Result<reqwest::blocking::Client, OpenApiError> {
182 reqwest::blocking::Client::builder()
183 .timeout(std::time::Duration::from_secs(60))
184 .build()
185 .map_err(|e| release_err(format!("build HTTP client: {e}")))
186}
187
188fn release_err(msg: String) -> OpenApiError {
189 OpenApiError::Release(msg)
190}
191
192#[derive(Debug, serde::Deserialize)]
193struct GithubContent {
194 name: String,
195 download_url: Option<String>,
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 #[test]
203 fn version_mapping() {
204 assert_eq!(version_to_tag("1.35"), "v1.35.0");
205 assert_eq!(version_to_tag("1.35.1"), "v1.35.1");
206 assert_eq!(version_to_tag("v1.35.0"), "v1.35.0");
207 assert_eq!(version_to_tag("1.30"), "v1.30.0");
208 }
209
210 #[test]
211 fn filename_conversion() {
212 assert_eq!(
213 filename_to_discovery_key("apis__apps__v1_openapi.json"),
214 "apis/apps/v1"
215 );
216 assert_eq!(filename_to_discovery_key("api__v1_openapi.json"), "api/v1");
217 assert_eq!(
218 filename_to_discovery_key("apis__batch__v1_openapi.json"),
219 "apis/batch/v1"
220 );
221 assert_eq!(
222 filename_to_discovery_key("apis__networking.k8s.io__v1_openapi.json"),
223 "apis/networking.k8s.io/v1"
224 );
225 }
226
227 #[test]
228 fn filter_spec_files() {
229 assert!(is_openapi_spec_file("apis__apps__v1_openapi.json"));
230 assert!(is_openapi_spec_file("api__v1_openapi.json"));
231 assert!(!is_openapi_spec_file("api_openapi.json")); assert!(!is_openapi_spec_file("README.md"));
233 assert!(!is_openapi_spec_file("swagger.json"));
234 }
235
236 #[test]
237 fn cache_round_trip() {
238 let tmp = tempfile::tempdir().unwrap();
239 let tag_cache = tmp.path().join("release/v1.35.0");
240 std::fs::create_dir_all(&tag_cache).unwrap();
241
242 let spec = serde_json::json!({"openapi": "3.0.0"});
244 std::fs::write(
245 tag_cache.join("apis__apps__v1_openapi.json"),
246 serde_json::to_string(&spec).unwrap(),
247 )
248 .unwrap();
249 let manifest = vec![(
250 "apis/apps/v1".to_string(),
251 "apis__apps__v1_openapi.json".to_string(),
252 )];
253 std::fs::write(
254 tag_cache.join("_manifest.json"),
255 serde_json::to_string(&manifest).unwrap(),
256 )
257 .unwrap();
258
259 let result = load_cached_specs(&tag_cache).unwrap();
260 assert_eq!(result.len(), 1);
261 assert!(result.contains_key("apis/apps/v1"));
262 assert_eq!(result["apis/apps/v1"]["openapi"], "3.0.0");
263 }
264
265 #[test]
266 fn cache_hit_skips_network() {
267 let tmp = tempfile::tempdir().unwrap();
268 let cache_dir = tmp.path();
269
270 let tag_cache = cache_dir.join("release/v1.35.0");
272 std::fs::create_dir_all(&tag_cache).unwrap();
273
274 let spec = serde_json::json!({"openapi": "3.0.0", "info": {"title": "cached"}});
275 std::fs::write(
276 tag_cache.join("api__v1_openapi.json"),
277 serde_json::to_string(&spec).unwrap(),
278 )
279 .unwrap();
280 let manifest = vec![("api/v1".to_string(), "api__v1_openapi.json".to_string())];
281 std::fs::write(
282 tag_cache.join("_manifest.json"),
283 serde_json::to_string(&manifest).unwrap(),
284 )
285 .unwrap();
286
287 let result = fetch_release_specs("1.35", cache_dir).unwrap();
289 assert_eq!(result.len(), 1);
290 assert_eq!(result["api/v1"]["info"]["title"], "cached");
291 }
292}