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 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 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 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 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 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 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 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 let result1 = client.fetch_spec("api/v1").unwrap();
374 assert_eq!(result1["openapi"], "3.0.0");
375
376 let result2 = client.fetch_spec("api/v1").unwrap();
378 assert_eq!(result2["openapi"], "3.0.0");
379
380 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 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 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 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 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 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 client.fetch_spec("api/v1").unwrap();
549
550 drop(_dm);
552 drop(_sm);
553
554 let _dm_fail = server.mock("GET", "/openapi/v3").with_status(500).create();
555
556 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 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}