Skip to main content

husako_openapi/
lib.rs

1mod cache;
2pub mod crd;
3mod fetch;
4pub mod kubeconfig;
5pub mod release;
6
7use std::collections::HashMap;
8use std::path::PathBuf;
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, thiserror::Error)]
14pub enum OpenApiError {
15    #[error("HTTP request failed: {0}")]
16    Http(String),
17    #[error("failed to read/write cache: {0}")]
18    Cache(String),
19    #[error("failed to parse OpenAPI response: {0}")]
20    Parse(String),
21    #[error("group-version not found: {0}")]
22    NotFound(String),
23    #[error("no cached data available for offline use")]
24    NoCachedData,
25    #[error("CRD parse error: {0}")]
26    Crd(String),
27    #[error("kubeconfig error: {0}")]
28    Kubeconfig(String),
29    #[error("GitHub release error: {0}")]
30    Release(String),
31}
32
33pub enum OpenApiSource {
34    Url {
35        base_url: String,
36        bearer_token: Option<String>,
37    },
38    Directory(PathBuf),
39}
40
41pub struct FetchOptions {
42    pub source: OpenApiSource,
43    pub cache_dir: PathBuf,
44    pub offline: bool,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct DiscoveryIndex {
49    pub paths: HashMap<String, DiscoveryPath>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct DiscoveryPath {
54    #[serde(rename = "serverRelativeURL")]
55    pub server_relative_url: String,
56}
57
58pub struct OpenApiClient {
59    http_client: Option<reqwest::blocking::Client>,
60    base_url: Option<String>,
61    bearer_token: Option<String>,
62    directory: Option<PathBuf>,
63    cache_dir: PathBuf,
64    offline: bool,
65}
66
67impl OpenApiClient {
68    pub fn new(options: FetchOptions) -> Result<Self, OpenApiError> {
69        match options.source {
70            OpenApiSource::Url {
71                base_url,
72                bearer_token,
73            } => {
74                let client = reqwest::blocking::Client::builder()
75                    .timeout(Duration::from_secs(30))
76                    .build()
77                    .map_err(|e| OpenApiError::Http(format!("failed to build HTTP client: {e}")))?;
78                Ok(Self {
79                    http_client: Some(client),
80                    base_url: Some(base_url),
81                    bearer_token,
82                    directory: None,
83                    cache_dir: options.cache_dir,
84                    offline: options.offline,
85                })
86            }
87            OpenApiSource::Directory(path) => Ok(Self {
88                http_client: None,
89                base_url: None,
90                bearer_token: None,
91                directory: Some(path),
92                cache_dir: options.cache_dir,
93                offline: true,
94            }),
95        }
96    }
97
98    pub fn discover(&self) -> Result<DiscoveryIndex, OpenApiError> {
99        if let Some(dir) = &self.directory {
100            return self.discover_from_directory(dir);
101        }
102
103        let base_url = self.base_url.as_deref().unwrap();
104        let key = cache::server_key(base_url);
105
106        if self.offline {
107            return cache::read_discovery(&self.cache_dir, &key)
108                .map_err(|_| OpenApiError::NoCachedData);
109        }
110
111        let client = self.http_client.as_ref().unwrap();
112        let token = self.bearer_token.as_deref();
113
114        match fetch::fetch_discovery(client, base_url, token) {
115            Ok(index) => {
116                let _ = cache::write_discovery(&self.cache_dir, &key, &index);
117                Ok(index)
118            }
119            Err(e) => {
120                // Fallback to cache on network failure
121                cache::read_discovery(&self.cache_dir, &key).map_err(|_| e)
122            }
123        }
124    }
125
126    pub fn fetch_spec(&self, group_version: &str) -> Result<serde_json::Value, OpenApiError> {
127        if let Some(dir) = &self.directory {
128            return self.read_spec_from_directory(dir, group_version);
129        }
130
131        let base_url = self.base_url.as_deref().unwrap();
132        let key = cache::server_key(base_url);
133
134        if self.offline {
135            return cache::read_spec(&self.cache_dir, &key, group_version)
136                .map_err(|_| OpenApiError::NoCachedData);
137        }
138
139        // Get discovery to find the server-relative URL and current hash
140        let index = self.discover()?;
141        let discovery_path = index
142            .paths
143            .get(group_version)
144            .ok_or_else(|| OpenApiError::NotFound(group_version.to_string()))?;
145
146        let new_hash = fetch::extract_hash(&discovery_path.server_relative_url);
147
148        // Check if cached hash matches
149        if let Some(ref new_h) = new_hash
150            && let Ok(cached_hashes) = cache::read_hashes(&self.cache_dir, &key)
151            && cached_hashes.get(group_version) == Some(new_h)
152            && let Ok(spec) = cache::read_spec(&self.cache_dir, &key, group_version)
153        {
154            return Ok(spec);
155        }
156
157        // Fetch from server
158        let client = self.http_client.as_ref().unwrap();
159        let token = self.bearer_token.as_deref();
160
161        match fetch::fetch_spec(client, base_url, &discovery_path.server_relative_url, token) {
162            Ok(spec) => {
163                let _ = cache::write_spec(&self.cache_dir, &key, group_version, &spec);
164                // Update hash
165                if let Some(new_h) = new_hash {
166                    let mut hashes = cache::read_hashes(&self.cache_dir, &key).unwrap_or_default();
167                    hashes.insert(group_version.to_string(), new_h);
168                    let _ = cache::write_hashes(&self.cache_dir, &key, &hashes);
169                }
170                Ok(spec)
171            }
172            Err(e) => {
173                // Fallback to cache
174                cache::read_spec(&self.cache_dir, &key, group_version).map_err(|_| e)
175            }
176        }
177    }
178
179    pub fn fetch_all_specs(&self) -> Result<HashMap<String, serde_json::Value>, OpenApiError> {
180        let index = self.discover()?;
181        let mut specs = HashMap::new();
182        for group_version in index.paths.keys() {
183            let spec = self.fetch_spec(group_version)?;
184            specs.insert(group_version.clone(), spec);
185        }
186        Ok(specs)
187    }
188
189    fn discover_from_directory(
190        &self,
191        dir: &std::path::Path,
192    ) -> Result<DiscoveryIndex, OpenApiError> {
193        let discovery_path = dir.join("discovery.json");
194        if discovery_path.exists() {
195            let data = std::fs::read_to_string(&discovery_path).map_err(|e| {
196                OpenApiError::Cache(format!("read {}: {e}", discovery_path.display()))
197            })?;
198            return serde_json::from_str(&data).map_err(|e| {
199                OpenApiError::Parse(format!("parse {}: {e}", discovery_path.display()))
200            });
201        }
202
203        // Build discovery from spec files in the directory
204        let mut paths = HashMap::new();
205        self.scan_spec_files(dir, dir, &mut paths)?;
206        Ok(DiscoveryIndex { paths })
207    }
208
209    fn scan_spec_files(
210        &self,
211        base: &std::path::Path,
212        dir: &std::path::Path,
213        paths: &mut HashMap<String, DiscoveryPath>,
214    ) -> Result<(), OpenApiError> {
215        let entries = std::fs::read_dir(dir)
216            .map_err(|e| OpenApiError::Cache(format!("read dir {}: {e}", dir.display())))?;
217        for entry in entries {
218            let entry = entry.map_err(|e| OpenApiError::Cache(format!("read entry: {e}")))?;
219            let path = entry.path();
220            if path.is_dir() {
221                self.scan_spec_files(base, &path, paths)?;
222            } else if path.extension().is_some_and(|ext| ext == "json")
223                && path.file_name().is_some_and(|n| n != "discovery.json")
224            {
225                let rel = path.strip_prefix(base).unwrap_or(&path).with_extension("");
226                let gv = rel.to_string_lossy().replace('\\', "/");
227                paths.insert(
228                    gv,
229                    DiscoveryPath {
230                        server_relative_url: String::new(),
231                    },
232                );
233            }
234        }
235        Ok(())
236    }
237
238    fn read_spec_from_directory(
239        &self,
240        dir: &std::path::Path,
241        group_version: &str,
242    ) -> Result<serde_json::Value, OpenApiError> {
243        let path = dir.join(format!("{group_version}.json"));
244        let data = std::fs::read_to_string(&path)
245            .map_err(|e| OpenApiError::Cache(format!("read {}: {e}", path.display())))?;
246        serde_json::from_str(&data)
247            .map_err(|e| OpenApiError::Parse(format!("parse {}: {e}", path.display())))
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    fn mock_discovery_json() -> serde_json::Value {
256        serde_json::json!({
257            "paths": {
258                "api/v1": {
259                    "serverRelativeURL": "/openapi/v3/api/v1?hash=HASH_A"
260                },
261                "apis/apps/v1": {
262                    "serverRelativeURL": "/openapi/v3/apis/apps/v1?hash=HASH_B"
263                }
264            }
265        })
266    }
267
268    fn mock_spec_json() -> serde_json::Value {
269        serde_json::json!({
270            "openapi": "3.0.0",
271            "info": { "title": "Kubernetes", "version": "v1.30.0" }
272        })
273    }
274
275    #[test]
276    fn discover_from_server() {
277        let mut server = mockito::Server::new();
278        let discovery = mock_discovery_json();
279        let mock = server
280            .mock("GET", "/openapi/v3")
281            .with_status(200)
282            .with_header("content-type", "application/json")
283            .with_body(discovery.to_string())
284            .create();
285
286        let tmp = tempfile::tempdir().unwrap();
287        let client = OpenApiClient::new(FetchOptions {
288            source: OpenApiSource::Url {
289                base_url: server.url(),
290                bearer_token: None,
291            },
292            cache_dir: tmp.path().to_path_buf(),
293            offline: false,
294        })
295        .unwrap();
296
297        let index = client.discover().unwrap();
298        assert_eq!(index.paths.len(), 2);
299        assert!(index.paths.contains_key("api/v1"));
300        assert!(index.paths.contains_key("apis/apps/v1"));
301        mock.assert();
302    }
303
304    #[test]
305    fn fetch_spec_from_server() {
306        let mut server = mockito::Server::new();
307        let discovery = mock_discovery_json();
308        let spec = mock_spec_json();
309
310        let _discovery_mock = server
311            .mock("GET", "/openapi/v3")
312            .with_status(200)
313            .with_header("content-type", "application/json")
314            .with_body(discovery.to_string())
315            .create();
316
317        let _spec_mock = server
318            .mock("GET", "/openapi/v3/api/v1?hash=HASH_A")
319            .with_status(200)
320            .with_header("content-type", "application/json")
321            .with_body(spec.to_string())
322            .create();
323
324        let tmp = tempfile::tempdir().unwrap();
325        let client = OpenApiClient::new(FetchOptions {
326            source: OpenApiSource::Url {
327                base_url: server.url(),
328                bearer_token: None,
329            },
330            cache_dir: tmp.path().to_path_buf(),
331            offline: false,
332        })
333        .unwrap();
334
335        let result = client.fetch_spec("api/v1").unwrap();
336        assert_eq!(result["openapi"], "3.0.0");
337    }
338
339    #[test]
340    fn cache_reuse_same_hash() {
341        let mut server = mockito::Server::new();
342        let discovery = mock_discovery_json();
343        let spec = mock_spec_json();
344
345        let discovery_mock = server
346            .mock("GET", "/openapi/v3")
347            .with_status(200)
348            .with_header("content-type", "application/json")
349            .with_body(discovery.to_string())
350            .expect_at_least(1)
351            .create();
352
353        let spec_mock = server
354            .mock("GET", "/openapi/v3/api/v1?hash=HASH_A")
355            .with_status(200)
356            .with_header("content-type", "application/json")
357            .with_body(spec.to_string())
358            .expect(1)
359            .create();
360
361        let tmp = tempfile::tempdir().unwrap();
362        let client = OpenApiClient::new(FetchOptions {
363            source: OpenApiSource::Url {
364                base_url: server.url(),
365                bearer_token: None,
366            },
367            cache_dir: tmp.path().to_path_buf(),
368            offline: false,
369        })
370        .unwrap();
371
372        // First fetch — hits server
373        let result1 = client.fetch_spec("api/v1").unwrap();
374        assert_eq!(result1["openapi"], "3.0.0");
375
376        // Second fetch — same hash, should use cache for spec
377        let result2 = client.fetch_spec("api/v1").unwrap();
378        assert_eq!(result2["openapi"], "3.0.0");
379
380        // Spec endpoint should only be hit once
381        spec_mock.assert();
382        discovery_mock.assert();
383    }
384
385    #[test]
386    fn cache_invalidation_hash_change() {
387        let mut server = mockito::Server::new();
388        let spec = mock_spec_json();
389        let updated_spec = serde_json::json!({
390            "openapi": "3.1.0",
391            "info": { "title": "Kubernetes", "version": "v1.31.0" }
392        });
393
394        // First discovery with HASH_A
395        let discovery_v1 = serde_json::json!({
396            "paths": {
397                "api/v1": { "serverRelativeURL": "/openapi/v3/api/v1?hash=HASH_A" }
398            }
399        });
400
401        let _discovery_mock_v1 = server
402            .mock("GET", "/openapi/v3")
403            .with_status(200)
404            .with_header("content-type", "application/json")
405            .with_body(discovery_v1.to_string())
406            .expect(1)
407            .create();
408
409        let _spec_mock_v1 = server
410            .mock("GET", "/openapi/v3/api/v1?hash=HASH_A")
411            .with_status(200)
412            .with_header("content-type", "application/json")
413            .with_body(spec.to_string())
414            .expect(1)
415            .create();
416
417        let tmp = tempfile::tempdir().unwrap();
418        let client = OpenApiClient::new(FetchOptions {
419            source: OpenApiSource::Url {
420                base_url: server.url(),
421                bearer_token: None,
422            },
423            cache_dir: tmp.path().to_path_buf(),
424            offline: false,
425        })
426        .unwrap();
427
428        let result1 = client.fetch_spec("api/v1").unwrap();
429        assert_eq!(result1["openapi"], "3.0.0");
430
431        // Remove old mocks by dropping them, create new ones with changed hash
432        drop(_discovery_mock_v1);
433        drop(_spec_mock_v1);
434
435        let discovery_v2 = serde_json::json!({
436            "paths": {
437                "api/v1": { "serverRelativeURL": "/openapi/v3/api/v1?hash=HASH_NEW" }
438            }
439        });
440
441        let _discovery_mock_v2 = server
442            .mock("GET", "/openapi/v3")
443            .with_status(200)
444            .with_header("content-type", "application/json")
445            .with_body(discovery_v2.to_string())
446            .expect(1)
447            .create();
448
449        let _spec_mock_v2 = server
450            .mock("GET", "/openapi/v3/api/v1?hash=HASH_NEW")
451            .with_status(200)
452            .with_header("content-type", "application/json")
453            .with_body(updated_spec.to_string())
454            .expect(1)
455            .create();
456
457        // Second fetch — hash changed, should re-fetch
458        let result2 = client.fetch_spec("api/v1").unwrap();
459        assert_eq!(result2["openapi"], "3.1.0");
460    }
461
462    #[test]
463    fn offline_mode_with_cache() {
464        let tmp = tempfile::tempdir().unwrap();
465        let key = cache::server_key("https://localhost:6443");
466
467        // Pre-populate cache
468        let index = DiscoveryIndex {
469            paths: HashMap::from([(
470                "api/v1".to_string(),
471                DiscoveryPath {
472                    server_relative_url: "/openapi/v3/api/v1?hash=CACHED".to_string(),
473                },
474            )]),
475        };
476        let spec = mock_spec_json();
477        cache::write_discovery(tmp.path(), &key, &index).unwrap();
478        cache::write_spec(tmp.path(), &key, "api/v1", &spec).unwrap();
479
480        let client = OpenApiClient::new(FetchOptions {
481            source: OpenApiSource::Url {
482                base_url: "https://localhost:6443".to_string(),
483                bearer_token: None,
484            },
485            cache_dir: tmp.path().to_path_buf(),
486            offline: true,
487        })
488        .unwrap();
489
490        let result_index = client.discover().unwrap();
491        assert_eq!(result_index.paths.len(), 1);
492
493        let result_spec = client.fetch_spec("api/v1").unwrap();
494        assert_eq!(result_spec["openapi"], "3.0.0");
495    }
496
497    #[test]
498    fn offline_mode_no_cache() {
499        let tmp = tempfile::tempdir().unwrap();
500        let client = OpenApiClient::new(FetchOptions {
501            source: OpenApiSource::Url {
502                base_url: "https://localhost:6443".to_string(),
503                bearer_token: None,
504            },
505            cache_dir: tmp.path().to_path_buf(),
506            offline: true,
507        })
508        .unwrap();
509
510        let err = client.discover().unwrap_err();
511        assert!(matches!(err, OpenApiError::NoCachedData));
512    }
513
514    #[test]
515    fn network_failure_cache_fallback() {
516        let mut server = mockito::Server::new();
517
518        // First, populate cache via successful fetch
519        let discovery = mock_discovery_json();
520        let spec = mock_spec_json();
521
522        let _dm = server
523            .mock("GET", "/openapi/v3")
524            .with_status(200)
525            .with_header("content-type", "application/json")
526            .with_body(discovery.to_string())
527            .create();
528
529        let _sm = server
530            .mock("GET", "/openapi/v3/api/v1?hash=HASH_A")
531            .with_status(200)
532            .with_header("content-type", "application/json")
533            .with_body(spec.to_string())
534            .create();
535
536        let tmp = tempfile::tempdir().unwrap();
537        let client = OpenApiClient::new(FetchOptions {
538            source: OpenApiSource::Url {
539                base_url: server.url(),
540                bearer_token: None,
541            },
542            cache_dir: tmp.path().to_path_buf(),
543            offline: false,
544        })
545        .unwrap();
546
547        // Populate cache
548        client.fetch_spec("api/v1").unwrap();
549
550        // Now make server return 500
551        drop(_dm);
552        drop(_sm);
553
554        let _dm_fail = server.mock("GET", "/openapi/v3").with_status(500).create();
555
556        // Should fall back to cache
557        let result = client.discover().unwrap();
558        assert_eq!(result.paths.len(), 2);
559    }
560
561    #[test]
562    fn network_failure_no_cache() {
563        let mut server = mockito::Server::new();
564        let _mock = server.mock("GET", "/openapi/v3").with_status(500).create();
565
566        let tmp = tempfile::tempdir().unwrap();
567        let client = OpenApiClient::new(FetchOptions {
568            source: OpenApiSource::Url {
569                base_url: server.url(),
570                bearer_token: None,
571            },
572            cache_dir: tmp.path().to_path_buf(),
573            offline: false,
574        })
575        .unwrap();
576
577        let err = client.discover().unwrap_err();
578        assert!(matches!(err, OpenApiError::Http(_)));
579    }
580
581    #[test]
582    fn directory_source() {
583        let tmp = tempfile::tempdir().unwrap();
584        let dir = tmp.path();
585
586        // Create spec files
587        std::fs::create_dir_all(dir.join("api")).unwrap();
588        std::fs::create_dir_all(dir.join("apis/apps")).unwrap();
589        std::fs::write(dir.join("api/v1.json"), mock_spec_json().to_string()).unwrap();
590        std::fs::write(dir.join("apis/apps/v1.json"), mock_spec_json().to_string()).unwrap();
591
592        let cache_tmp = tempfile::tempdir().unwrap();
593        let client = OpenApiClient::new(FetchOptions {
594            source: OpenApiSource::Directory(dir.to_path_buf()),
595            cache_dir: cache_tmp.path().to_path_buf(),
596            offline: true,
597        })
598        .unwrap();
599
600        let index = client.discover().unwrap();
601        assert_eq!(index.paths.len(), 2);
602
603        let spec = client.fetch_spec("api/v1").unwrap();
604        assert_eq!(spec["openapi"], "3.0.0");
605    }
606
607    #[test]
608    fn fetch_all_specs_integration() {
609        let mut server = mockito::Server::new();
610        let discovery = mock_discovery_json();
611        let spec = mock_spec_json();
612
613        let _dm = server
614            .mock("GET", "/openapi/v3")
615            .with_status(200)
616            .with_header("content-type", "application/json")
617            .with_body(discovery.to_string())
618            .create();
619
620        let _sm1 = server
621            .mock("GET", "/openapi/v3/api/v1?hash=HASH_A")
622            .with_status(200)
623            .with_header("content-type", "application/json")
624            .with_body(spec.to_string())
625            .create();
626
627        let _sm2 = server
628            .mock("GET", "/openapi/v3/apis/apps/v1?hash=HASH_B")
629            .with_status(200)
630            .with_header("content-type", "application/json")
631            .with_body(spec.to_string())
632            .create();
633
634        let tmp = tempfile::tempdir().unwrap();
635        let client = OpenApiClient::new(FetchOptions {
636            source: OpenApiSource::Url {
637                base_url: server.url(),
638                bearer_token: None,
639            },
640            cache_dir: tmp.path().to_path_buf(),
641            offline: false,
642        })
643        .unwrap();
644
645        let all = client.fetch_all_specs().unwrap();
646        assert_eq!(all.len(), 2);
647        assert!(all.contains_key("api/v1"));
648        assert!(all.contains_key("apis/apps/v1"));
649    }
650}