Skip to main content

husako_openapi/
release.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use serde_json::Value;
5
6use crate::OpenApiError;
7
8/// Fetch Kubernetes OpenAPI specs from a GitHub release tag.
9///
10/// `version` can be `"1.35"` (mapped to `v1.35.0`) or a full semver like `"1.35.1"`.
11/// Results are cached under `cache_dir/release/{tag}/`.
12pub 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    // Check cache first — tag-based caching is deterministic
20    if tag_cache.exists() {
21        return load_cached_specs(&tag_cache);
22    }
23
24    // List spec files from the GitHub API
25    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    // Filter for OpenAPI spec files
49    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    // Download each spec
61    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        // Cache individual spec file
93        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    // Write a manifest for quick cache loading
103    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
113/// Convert version string to a git tag.
114/// `"1.35"` → `"v1.35.0"`, `"1.35.1"` → `"v1.35.1"`, `"v1.35.0"` → `"v1.35.0"`
115pub 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
125/// Convert a spec filename to a discovery key.
126/// `apis__apps__v1_openapi.json` → `apis/apps/v1`
127pub fn filename_to_discovery_key(filename: &str) -> String {
128    filename
129        .trim_end_matches("_openapi.json")
130        .replace("__", "/")
131}
132
133/// Check if a filename looks like an OpenAPI spec file.
134fn 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    // Fallback: scan JSON files
159    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")); // excluded
232        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        // Write a fake spec + manifest
243        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        // Pre-populate cache
271        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        // This should hit cache and NOT make any network requests
288        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}